Merge branch 'machine-roles' into dev

This commit is contained in:
Owen
2026-10-12 16:25:13 -04:00
18 changed files with 1035 additions and 128 deletions
+24 -2
View File
@@ -1,6 +1,14 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idp, idpOidcConfig, olms, users } from "@server/db";
import {
clientOrgRoles,
db,
idp,
idpOidcConfig,
olms,
roles,
users
} from "@server/db";
import { clients, currentFingerprint } from "@server/db";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -239,6 +247,8 @@ export type GetClientResponse = NonNullable<
userType: string | null;
idpName: string | null;
idpVariant: string | null;
// roles assigned directly to the client (machine clients only)
roles: { roleId: number; name: string }[];
};
registry.registerPath({
@@ -394,6 +404,17 @@ export async function getClient(
}
}
const clientRoles = await db
.select({ roleId: roles.roleId, name: roles.name })
.from(clientOrgRoles)
.innerJoin(roles, eq(roles.roleId, clientOrgRoles.roleId))
.where(
and(
eq(clientOrgRoles.clientId, client.clients.clientId),
eq(clientOrgRoles.orgId, client.clients.orgId)
)
);
const data: GetClientResponse = {
...client.clients,
name: clientName,
@@ -407,7 +428,8 @@ export async function getClient(
posture: postureData,
userType,
idpName,
idpVariant
idpVariant,
roles: clientRoles
};
return response<GetClientResponse>(res, {
+70 -2
View File
@@ -1,5 +1,6 @@
import {
clientLabels,
clientOrgRoles,
clients,
clientSitesAssociationsCache,
currentFingerprint,
@@ -8,6 +9,7 @@ import {
olms,
orgs,
roleClients,
roles,
sites,
userClients,
users,
@@ -25,6 +27,7 @@ import {
asc,
desc,
eq,
exists,
inArray,
isNull,
like,
@@ -138,6 +141,30 @@ const listClientsSchema = z.strictObject({
.openapi({
type: "array",
description: "Filter by client labels"
}),
role_id: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
const raw = Array.isArray(val)
? val
: typeof val === "string"
? val.split(",")
: [val];
const ids = raw
.map((v) =>
typeof v === "string" ? parseInt(v, 10) : Number(v)
)
.filter((n) => Number.isInteger(n) && n > 0);
return ids.length > 0 ? [...new Set(ids)] : undefined;
}, z.array(z.number().int().positive()).optional())
.catch(undefined)
.openapi({
type: "array",
items: { type: "integer" },
description:
"Filter clients that have any of these role ids assigned (repeat query param or comma-separated)"
})
});
@@ -195,6 +222,7 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
}>;
olmUpdateAvailable?: boolean;
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
roles: Array<{ roleId: number; name: string }>;
};
type OlmWithUpdateAvailable = ClientWithSites;
@@ -253,7 +281,8 @@ export async function listClients(
status,
sort_by,
order,
labels: labelFilter
labels: labelFilter,
role_id: roleIdFilter
} = parsedQuery.data;
const parsedParams = listClientsParamsSchema.safeParse(req.params);
@@ -352,6 +381,23 @@ export async function listClients(
);
}
if (roleIdFilter && roleIdFilter.length > 0) {
conditions.push(
exists(
db
.select()
.from(clientOrgRoles)
.where(
and(
eq(clientOrgRoles.clientId, clients.clientId),
eq(clientOrgRoles.orgId, orgId),
inArray(clientOrgRoles.roleId, roleIdFilter)
)
)
)
);
}
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
@@ -421,6 +467,25 @@ export async function listClients(
.orderBy(asc(clientLabels.clientLabelId));
}
const rolesForClients =
clientIds.length > 0
? await db
.select({
clientId: clientOrgRoles.clientId,
roleId: roles.roleId,
name: roles.name
})
.from(clientOrgRoles)
.innerJoin(roles, eq(roles.roleId, clientOrgRoles.roleId))
.where(
and(
inArray(clientOrgRoles.clientId, clientIds),
eq(clientOrgRoles.orgId, orgId)
)
)
.orderBy(asc(roles.name))
: [];
// Group site associations by client ID
const sitesByClient = siteAssociations.reduce(
(acc, association) => {
@@ -451,7 +516,10 @@ export async function listClients(
sites: sitesByClient[client.clientId] || [],
labels: labelsForClients.filter(
(l) => l.clientId === client.clientId
)
),
roles: rolesForClients
.filter((r) => r.clientId === client.clientId)
.map(({ roleId, name }) => ({ roleId, name }))
};
});
+24 -1
View File
@@ -1,8 +1,9 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { Client, clientOrgRoles, clients, db } from "@server/db";
import { roles, userOrgRoles } from "@server/db";
import { and, eq, exists, aliasedTable } from "drizzle-orm";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -128,7 +129,21 @@ export async function deleteRole(
);
}
let machineClientsToRebuild: Client[] = [];
await db.transaction(async (trx) => {
// machine clients in this role lose it when the role is deleted
// (clientOrgRoles cascades) and are not moved to the new role, so
// their associations need to be rebuilt afterwards
machineClientsToRebuild = await trx
.select({ client: clients })
.from(clientOrgRoles)
.innerJoin(
clients,
eq(clients.clientId, clientOrgRoles.clientId)
)
.where(eq(clientOrgRoles.roleId, roleId))
.then((rows) => rows.map((row) => row.client));
const uorNewRole = aliasedTable(userOrgRoles, "user_org_roles_new");
// Users who already have newRoleId: drop the old assignment only (unique on userId+orgId+roleId).
@@ -158,6 +173,14 @@ export async function deleteRole(
await trx.delete(roles).where(eq(roles.roleId, roleId));
});
for (const machineClient of machineClientsToRebuild) {
rebuildClientAssociationsFromClient(machineClient).catch((e) => {
logger.error(
`Failed to rebuild client associations for client ${machineClient.clientId} after deleting role ${roleId}: ${e}`
);
});
}
return response(res, {
data: null,
success: true,