mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-21 16:25:19 +00:00
Verify button to verify cache
This commit is contained in:
@@ -1528,3 +1528,195 @@ async function handleMessagesForClientResources(
|
|||||||
|
|
||||||
await Promise.all([...proxyJobs, ...olmJobs]);
|
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<ClientAssociationsCacheVerification> {
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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 client from "@server/routers/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -775,3 +776,9 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.getTarget),
|
verifyUserHasAction(ActionsEnum.getTarget),
|
||||||
healthChecks.getHealthCheckStatusHistory
|
healthChecks.getHealthCheckStatusHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/client/:clientId/verify-associations-cache",
|
||||||
|
verifyClientAccess,
|
||||||
|
client.verifyClientAssociationsCache
|
||||||
|
);
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from "./listUserDevices";
|
|||||||
export * from "./updateClient";
|
export * from "./updateClient";
|
||||||
export * from "./getClient";
|
export * from "./getClient";
|
||||||
export * from "./createUserClient";
|
export * from "./createUserClient";
|
||||||
|
export * from "./verifyClientAssociationsCache";
|
||||||
|
|||||||
83
server/routers/client/verifyClientAssociationsCache.ts
Normal file
83
server/routers/client/verifyClientAssociationsCache.ts
Normal file
@@ -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<any> {
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,6 +153,37 @@ export default function GeneralPage() {
|
|||||||
const [approvalId, setApprovalId] = useState<number | null>(null);
|
const [approvalId, setApprovalId] = useState<number | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
const [cacheCheck, setCacheCheck] = useState<null | {
|
||||||
|
consistent: boolean;
|
||||||
|
missingSiteResourceIds: number[];
|
||||||
|
extraSiteResourceIds: number[];
|
||||||
|
missingSiteIds: number[];
|
||||||
|
extraSiteIds: number[];
|
||||||
|
expectedSiteResourceIds: number[];
|
||||||
|
actualSiteResourceIds: number[];
|
||||||
|
expectedSiteIds: number[];
|
||||||
|
actualSiteIds: number[];
|
||||||
|
}>(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 { env } = useEnvContext();
|
||||||
|
|
||||||
const showApprovalFeatures =
|
const showApprovalFeatures =
|
||||||
@@ -844,6 +875,65 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hidden cache verification — subtle button, dev/admin diagnostic */}
|
||||||
|
<div className="mt-8 flex flex-col gap-2 items-start opacity-30 hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleVerifyCache}
|
||||||
|
disabled={isCheckingCache}
|
||||||
|
className="text-xs text-muted-foreground underline disabled:opacity-50"
|
||||||
|
title="Verify the client's site association cache against current permissions (read-only)"
|
||||||
|
>
|
||||||
|
{isCheckingCache
|
||||||
|
? "Checking cache…"
|
||||||
|
: "Verify association cache"}
|
||||||
|
</button>
|
||||||
|
{cacheCheck && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"text-xs rounded border px-2 py-1 " +
|
||||||
|
(cacheCheck.consistent
|
||||||
|
? "border-green-600 text-green-700"
|
||||||
|
: "border-red-600 text-red-700")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cacheCheck.consistent ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
Cache is consistent
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1 font-semibold">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Cache is INCONSISTENT
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Missing site resources: [
|
||||||
|
{cacheCheck.missingSiteResourceIds.join(
|
||||||
|
", "
|
||||||
|
)}
|
||||||
|
]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Extra site resources: [
|
||||||
|
{cacheCheck.extraSiteResourceIds.join(", ")}
|
||||||
|
]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Missing sites: [
|
||||||
|
{cacheCheck.missingSiteIds.join(", ")}]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Extra sites: [
|
||||||
|
{cacheCheck.extraSiteIds.join(", ")}]
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user