From 1a0db10b1af5269ea14d06192093ca21b6444705 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 20 May 2026 11:15:15 -0700 Subject: [PATCH] Verify button to verify cache --- server/lib/rebuildClientAssociations.ts | 192 ++++++++++++++++++ server/private/routers/external.ts | 7 + server/routers/client/index.ts | 1 + .../client/verifyClientAssociationsCache.ts | 83 ++++++++ .../clients/user/[niceId]/general/page.tsx | 90 ++++++++ 5 files changed, 373 insertions(+) create mode 100644 server/routers/client/verifyClientAssociationsCache.ts diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index a7a134f6d..d881e8eeb 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1528,3 +1528,195 @@ async function handleMessagesForClientResources( await Promise.all([...proxyJobs, ...olmJobs]); } + +export type ClientAssociationsCacheVerification = { + clientId: number; + consistent: boolean; + // What permissions say the cache should contain + expectedSiteResourceIds: number[]; + expectedSiteIds: number[]; + // What the cache currently contains + actualSiteResourceIds: number[]; + actualSiteIds: number[]; + // Diff + missingSiteResourceIds: number[]; // present in expected, missing from cache + extraSiteResourceIds: number[]; // present in cache, not in expected + missingSiteIds: number[]; + extraSiteIds: number[]; +}; + +// verifyClientAssociationsCache walks the same permission-derivation logic as +// rebuildClientAssociationsFromClient but does NOT modify the database. It +// returns the expected vs actual cache contents and a boolean indicating +// whether the cache is in sync with what permissions imply. +export async function verifyClientAssociationsCache( + client: Client, + trx: Transaction | typeof db = db +): Promise { + let newSiteResourceIds: number[] = []; + + // 1. Direct client associations + const directSiteResources = await trx + .select({ siteResourceId: clientSiteResources.siteResourceId }) + .from(clientSiteResources) + .innerJoin( + siteResources, + eq(siteResources.siteResourceId, clientSiteResources.siteResourceId) + ) + .where( + and( + eq(clientSiteResources.clientId, client.clientId), + eq(siteResources.orgId, client.orgId) + ) + ); + + newSiteResourceIds.push( + ...directSiteResources.map((r) => r.siteResourceId) + ); + + // 2. User-based and role-based access (if client has a userId) + if (client.userId) { + const userSiteResourceIds = await trx + .select({ siteResourceId: userSiteResources.siteResourceId }) + .from(userSiteResources) + .innerJoin( + siteResources, + eq( + siteResources.siteResourceId, + userSiteResources.siteResourceId + ) + ) + .where( + and( + eq(userSiteResources.userId, client.userId), + eq(siteResources.orgId, client.orgId) + ) + ); + + newSiteResourceIds.push( + ...userSiteResourceIds.map((r) => r.siteResourceId) + ); + + const roleIds = await trx + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, client.userId), + eq(userOrgRoles.orgId, client.orgId) + ) + ) + .then((rows) => rows.map((row) => row.roleId)); + + if (roleIds.length > 0) { + const roleSiteResourceIds = await trx + .select({ siteResourceId: roleSiteResources.siteResourceId }) + .from(roleSiteResources) + .innerJoin( + siteResources, + eq( + siteResources.siteResourceId, + roleSiteResources.siteResourceId + ) + ) + .where( + and( + inArray(roleSiteResources.roleId, roleIds), + eq(siteResources.orgId, client.orgId) + ) + ); + + newSiteResourceIds.push( + ...roleSiteResourceIds.map((r) => r.siteResourceId) + ); + } + } + + newSiteResourceIds = Array.from(new Set(newSiteResourceIds)); + + const newSiteResources = + newSiteResourceIds.length > 0 + ? await trx + .select() + .from(siteResources) + .where( + inArray(siteResources.siteResourceId, newSiteResourceIds) + ) + : []; + + const networkIds = Array.from( + new Set( + newSiteResources + .map((sr) => sr.networkId) + .filter((id): id is number => id !== null) + ) + ); + const newSiteIds = + networkIds.length > 0 + ? await trx + .select({ siteId: siteNetworks.siteId }) + .from(siteNetworks) + .where(inArray(siteNetworks.networkId, networkIds)) + .then((rows) => + Array.from(new Set(rows.map((r) => r.siteId))) + ) + : []; + + // Read the existing cache state + const existingResourceAssociations = await trx + .select({ + siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId + }) + .from(clientSiteResourcesAssociationsCache) + .where( + eq(clientSiteResourcesAssociationsCache.clientId, client.clientId) + ); + const existingSiteResourceIds = existingResourceAssociations.map( + (r) => r.siteResourceId + ); + + const existingSiteAssociations = await trx + .select({ siteId: clientSitesAssociationsCache.siteId }) + .from(clientSitesAssociationsCache) + .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); + const existingSiteIds = existingSiteAssociations.map((s) => s.siteId); + + const expectedSiteResourceSet = new Set(newSiteResourceIds); + const actualSiteResourceSet = new Set(existingSiteResourceIds); + const expectedSiteSet = new Set(newSiteIds); + const actualSiteSet = new Set(existingSiteIds); + + const missingSiteResourceIds = newSiteResourceIds.filter( + (id) => !actualSiteResourceSet.has(id) + ); + const extraSiteResourceIds = existingSiteResourceIds.filter( + (id) => !expectedSiteResourceSet.has(id) + ); + const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id)); + const extraSiteIds = existingSiteIds.filter( + (id) => !expectedSiteSet.has(id) + ); + + const consistent = + missingSiteResourceIds.length === 0 && + extraSiteResourceIds.length === 0 && + missingSiteIds.length === 0 && + extraSiteIds.length === 0; + + return { + clientId: client.clientId, + consistent, + expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort( + (a, b) => a - b + ), + expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b), + actualSiteResourceIds: Array.from(actualSiteResourceSet).sort( + (a, b) => a - b + ), + actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b), + missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b), + extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b), + missingSiteIds: missingSiteIds.sort((a, b) => a - b), + extraSiteIds: extraSiteIds.sort((a, b) => a - b) + }; +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index a2667daa1..a48cc3813 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,6 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; +import * as client from "@server/routers/client"; import { verifyOrgAccess, @@ -775,3 +776,9 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getTarget), healthChecks.getHealthCheckStatusHistory ); + +authenticated.get( + "/client/:clientId/verify-associations-cache", + verifyClientAccess, + client.verifyClientAssociationsCache +); diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index e195d1c52..145cdd306 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -10,3 +10,4 @@ export * from "./listUserDevices"; export * from "./updateClient"; export * from "./getClient"; export * from "./createUserClient"; +export * from "./verifyClientAssociationsCache"; diff --git a/server/routers/client/verifyClientAssociationsCache.ts b/server/routers/client/verifyClientAssociationsCache.ts new file mode 100644 index 000000000..6b701ded3 --- /dev/null +++ b/server/routers/client/verifyClientAssociationsCache.ts @@ -0,0 +1,83 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } 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 { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations"; + +const paramsSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "get", + path: "/client/{clientId}/verify-associations-cache", + description: + "Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.", + tags: [OpenAPITags.Client], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function verifyClientAssociationsCache( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + const report = await verifyClientAssociationsCacheLib(client); + + return response(res, { + data: report, + success: true, + error: false, + message: report.consistent + ? "Client association cache is consistent" + : "Client association cache is INCONSISTENT", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to verify client association cache" + ) + ); + } +} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index c08551865..af2d5fdad 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -153,6 +153,37 @@ export default function GeneralPage() { const [approvalId, setApprovalId] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const [, startTransition] = useTransition(); + const [cacheCheck, setCacheCheck] = useState(null); + const [isCheckingCache, setIsCheckingCache] = useState(false); + + const handleVerifyCache = async () => { + if (!client.clientId) return; + setIsCheckingCache(true); + try { + const res = await api.get( + `/client/${client.clientId}/verify-associations-cache` + ); + setCacheCheck(res.data.data); + } catch (e) { + toast({ + variant: "destructive", + title: "Cache check failed", + description: formatAxiosError(e, "Failed to verify cache") + }); + } finally { + setIsCheckingCache(false); + } + }; const { env } = useEnvContext(); const showApprovalFeatures = @@ -844,6 +875,65 @@ export default function GeneralPage() { )} + + {/* Hidden cache verification — subtle button, dev/admin diagnostic */} +
+ + {cacheCheck && ( +
+ {cacheCheck.consistent ? ( + + + Cache is consistent + + ) : ( +
+
+ + Cache is INCONSISTENT +
+
+ Missing site resources: [ + {cacheCheck.missingSiteResourceIds.join( + ", " + )} + ] +
+
+ Extra site resources: [ + {cacheCheck.extraSiteResourceIds.join(", ")} + ] +
+
+ Missing sites: [ + {cacheCheck.missingSiteIds.join(", ")}] +
+
+ Extra sites: [ + {cacheCheck.extraSiteIds.join(", ")}] +
+
+ )} +
+ )} +
); }