diff --git a/messages/en-US.json b/messages/en-US.json index c2d3608f6..77e182f4c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1567,6 +1567,9 @@ "actionListUsers": "List Users", "actionAddUserRole": "Add User Role", "actionSetUserOrgRoles": "Set User Roles", + "actionAddClientRole": "Add Machine Client Role", + "actionRemoveClientRole": "Remove Machine Client Role", + "actionSetClientRoles": "Set Machine Client Roles", "actionGenerateAccessToken": "Generate Access Token", "actionDeleteAccessToken": "Delete Access Token", "actionListAccessTokens": "List Access Tokens", @@ -2735,6 +2738,11 @@ "clientUpdatedDescription": "The client has been updated.", "clientUpdateFailed": "Failed to update client", "clientUpdateError": "An error occurred while updating the client.", + "machineClientRolesDescription": "Assign roles to this machine client. It will be able to access the private resources granted to these roles.", + "machineClientRolesUpdated": "Roles updated", + "machineClientRolesUpdatedDescription": "The machine client's roles have been updated.", + "machineClientRolesUpdateFailed": "Failed to update roles", + "machineClientRolesUpdateError": "An error occurred while updating the machine client's roles.", "sitesFetchFailed": "Failed to fetch sites", "sitesFetchError": "An error occurred while fetching sites.", "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 75caed82d..70a7755aa 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -61,6 +61,9 @@ export enum ActionsEnum { addUserRole = "addUserRole", removeUserRole = "removeUserRole", setUserOrgRoles = "setUserOrgRoles", + addClientRole = "addClientRole", + removeClientRole = "removeClientRole", + setClientRoles = "setClientRoles", // addUserSite = "addUserSite", // addUserAction = "addUserAction", // removeUserAction = "removeUserAction", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 47457680f..930a830ef 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -808,6 +808,27 @@ export const userOrgRoles = pgTable( (t) => [unique().on(t.userId, t.orgId, t.roleId)] ); +// roles assigned to machine clients (clients with no userId) so they can be +// granted access to site resources through roleSiteResources like users are +export const clientOrgRoles = pgTable( + "clientOrgRoles", + { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [ + unique().on(t.clientId, t.orgId, t.roleId), + index("idx_clientorgroles_roleid").on(t.roleId) + ] +); + export const roleActions = pgTable( "roleActions", { @@ -2010,6 +2031,7 @@ export type UserInvite = InferSelectModel; export type UserInviteRole = InferSelectModel; export type UserOrg = InferSelectModel; export type UserOrgRole = InferSelectModel; +export type ClientOrgRole = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index c2d23e93b..4bfe553c9 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1095,6 +1095,27 @@ export const userOrgRoles = sqliteTable( (t) => [unique().on(t.userId, t.orgId, t.roleId)] ); +// roles assigned to machine clients (clients with no userId) so they can be +// granted access to site resources through roleSiteResources like users are +export const clientOrgRoles = sqliteTable( + "clientOrgRoles", + { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [ + unique().on(t.clientId, t.orgId, t.roleId), + index("idx_clientOrgRoles_roleId").on(t.roleId) + ] +); + export const roleActions = sqliteTable("roleActions", { roleId: integer("roleId") .notNull() @@ -2053,6 +2074,7 @@ export type UserInvite = InferSelectModel; export type UserInviteRole = InferSelectModel; export type UserOrg = InferSelectModel; export type UserOrgRole = InferSelectModel; +export type ClientOrgRole = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index b5a164c51..4dbd096f8 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1,5 +1,6 @@ import { Client, + clientOrgRoles, clients, clientSiteResources, clientSiteResourcesAssociationsCache, @@ -256,9 +257,30 @@ export async function getClientSiteResourceAccess( eq(clientSiteResources.siteResourceId, siteResource.siteResourceId) ); - const directClientIds = allClientSiteResources.map((row) => row.clientId); + // clients (machines) that get access through a role assigned directly to + // the client instead of through a user + const roleClientIds = + roleIds.length > 0 + ? await trx + .select({ clientId: clientOrgRoles.clientId }) + .from(clientOrgRoles) + .where( + and( + inArray(clientOrgRoles.roleId, roleIds), + eq(clientOrgRoles.orgId, siteResource.orgId) + ) + ) + .then((rows) => rows.map((row) => row.clientId)) + : []; - // Get full client details for directly associated clients + const directClientIds = Array.from( + new Set([ + ...allClientSiteResources.map((row) => row.clientId), + ...roleClientIds + ]) + ); + + // Get full client details for directly and client-role associated clients const directClients = directClientIds.length > 0 ? await trx @@ -285,7 +307,7 @@ export async function getClientSiteResourceAccess( const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); logger.debug( - `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} mergedClientCount=${mergedAllClientIds.length} clientIds=[${mergedAllClientIds.join(", ")}] (userBased=${newAllClients.length} direct=${directClients.length})` + `rebuildClientAssociations: [getClientSiteResourceAccess] siteResourceId=${siteResource.siteResourceId} mergedClientCount=${mergedAllClientIds.length} clientIds=[${mergedAllClientIds.join(", ")}] (userBased=${newAllClients.length} direct=${directClients.length} clientRoleBased=${roleClientIds.length})` ); return { @@ -2149,11 +2171,16 @@ export async function rebuildClientAssociationsFromClient( } } -async function rebuildClientAssociationsFromClientImpl( +// Derives the set of site resources a client should have access to from its +// permissions: direct client associations, the owning user's direct and +// role-based associations, and roles assigned directly to the client +// (machine clients). Shared by the rebuild and the cache verification so they +// can't drift apart. +async function getClientSiteResourceIds( client: Client, trx: Transaction | typeof db = db -): Promise { - let newSiteResourceIds: number[] = []; +): Promise { + const newSiteResourceIds: number[] = []; // 1. Direct client associations const directSiteResources = await trx @@ -2174,7 +2201,20 @@ async function rebuildClientAssociationsFromClientImpl( ...directSiteResources.map((r) => r.siteResourceId) ); - // 2. User-based and role-based access (if client has a userId) + // 2. Roles assigned directly to the client (machine clients). These are + // locked onto this org or else cross-org access could happen + const roleIds = await trx + .select({ roleId: clientOrgRoles.roleId }) + .from(clientOrgRoles) + .where( + and( + eq(clientOrgRoles.clientId, client.clientId), + eq(clientOrgRoles.orgId, client.orgId) + ) + ) + .then((rows) => rows.map((row) => row.roleId)); + + // 3. User-based and user role-based access (if client has a userId) if (client.userId) { // Direct user associations const userSiteResourceIds = await trx @@ -2198,8 +2238,7 @@ async function rebuildClientAssociationsFromClientImpl( ...userSiteResourceIds.map((r) => r.siteResourceId) ); - // Role-based access - const roleIds = await trx + const userRoleIds = await trx .select({ roleId: userOrgRoles.roleId }) .from(userOrgRoles) .where( @@ -2210,32 +2249,45 @@ async function rebuildClientAssociationsFromClientImpl( ) // this needs to be locked onto this org or else cross-org access could happen .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) // filter by org to prevent cross-org associations - ) - ); + roleIds.push(...userRoleIds); + } - newSiteResourceIds.push( - ...roleSiteResourceIds.map((r) => r.siteResourceId) + // Role-based access (client roles and user roles) + 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, + Array.from(new Set(roleIds)) + ), + eq(siteResources.orgId, client.orgId) // filter by org to prevent cross-org associations + ) ); - } + + newSiteResourceIds.push( + ...roleSiteResourceIds.map((r) => r.siteResourceId) + ); } // Remove duplicates - newSiteResourceIds = Array.from(new Set(newSiteResourceIds)); + return Array.from(new Set(newSiteResourceIds)); +} + +async function rebuildClientAssociationsFromClientImpl( + client: Client, + trx: Transaction | typeof db = db +): Promise { + const newSiteResourceIds = await getClientSiteResourceIds(client, trx); // Get full siteResource details const newSiteResources = @@ -2947,86 +2999,7 @@ 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 newSiteResourceIds = await getClientSiteResourceIds(client, trx); const newSiteResources = newSiteResourceIds.length > 0 diff --git a/server/private/routers/client/addClientRole.ts b/server/private/routers/client/addClientRole.ts new file mode 100644 index 000000000..27bc9eb1a --- /dev/null +++ b/server/private/routers/client/addClientRole.ts @@ -0,0 +1,161 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { clientOrgRoles, clients, db, roles } 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 { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; + +const addClientRoleParamsSchema = z.strictObject({ + clientId: z.coerce.number().int().positive(), + roleId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/add-role/{roleId}", + description: + "Add a role to a machine client. The client is granted access to the private resources assigned to the role. Clients with a userId cannot be added to roles; they inherit the roles of their user.", + tags: [OpenAPITags.Role, OpenAPITags.Client], + request: { + params: addClientRoleParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function addClientRole( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = addClientRoleParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId, roleId } = parsedParams.data; + + if (req.user && !req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + if (!role) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") + ); + } + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client || client.orgId !== role.orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Client not found or does not belong to the specified organization" + ) + ); + } + + if (client.userId !== null) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot add roles to clients that are associated with a user" + ) + ); + } + + if (await isOrgRebuildRateLimited(role.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + + await db + .insert(clientOrgRoles) + .values({ + clientId, + orgId: role.orgId, + roleId + }) + .onConflictDoNothing(); + + rebuildClientAssociationsFromClient(client).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${clientId} after adding role: ${e}` + ); + }); + + return response(res, { + data: { clientId, orgId: role.orgId, roleId }, + success: true, + error: false, + message: "Role added to client successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/client/index.ts b/server/private/routers/client/index.ts new file mode 100644 index 000000000..f6b9ab875 --- /dev/null +++ b/server/private/routers/client/index.ts @@ -0,0 +1,16 @@ +/* + * 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 "./addClientRole"; +export * from "./removeClientRole"; +export * from "./setClientRoles"; diff --git a/server/private/routers/client/removeClientRole.ts b/server/private/routers/client/removeClientRole.ts new file mode 100644 index 000000000..6b69bcf7c --- /dev/null +++ b/server/private/routers/client/removeClientRole.ts @@ -0,0 +1,153 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { clientOrgRoles, clients, db, roles } from "@server/db"; +import { and, 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 { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; + +const removeClientRoleParamsSchema = z.strictObject({ + clientId: z.coerce.number().int().positive(), + roleId: z.coerce.number().int().positive() +}); + +registry.registerPath({ + method: "delete", + path: "/client/{clientId}/remove-role/{roleId}", + description: + "Remove a role from a machine client. Unlike users, machine clients are not required to keep at least one role.", + tags: [OpenAPITags.Role, OpenAPITags.Client], + request: { + params: removeClientRoleParamsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function removeClientRole( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = removeClientRoleParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId, roleId } = parsedParams.data; + + if (req.user && !req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + if (!role) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") + ); + } + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client || client.orgId !== role.orgId) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Client not found or does not belong to the specified organization" + ) + ); + } + + if (await isOrgRebuildRateLimited(role.orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + + await db + .delete(clientOrgRoles) + .where( + and( + eq(clientOrgRoles.clientId, clientId), + eq(clientOrgRoles.orgId, role.orgId), + eq(clientOrgRoles.roleId, roleId) + ) + ); + + rebuildClientAssociationsFromClient(client).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${clientId} after removing role: ${e}` + ); + }); + + return response(res, { + data: { clientId, orgId: role.orgId, roleId }, + success: true, + error: false, + message: "Role removed from client successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/client/setClientRoles.ts b/server/private/routers/client/setClientRoles.ts new file mode 100644 index 000000000..23d9b7b9b --- /dev/null +++ b/server/private/routers/client/setClientRoles.ts @@ -0,0 +1,204 @@ +/* + * 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 { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { clientOrgRoles, clients, db, roles } from "@server/db"; +import { and, eq, inArray } 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 { + rebuildClientAssociationsFromClient, + isOrgRebuildRateLimited +} from "@server/lib/rebuildClientAssociations"; + +const setClientRolesParamsSchema = z.strictObject({ + clientId: z.coerce.number().int().positive() +}); + +const setClientRolesBodySchema = z.strictObject({ + roleIds: z.array(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/roles", + description: + "Set all roles for a machine client, replacing any existing roles. An empty list removes all roles. Clients with a userId cannot be assigned roles; they inherit the roles of their user.", + tags: [OpenAPITags.Role, OpenAPITags.Client], + request: { + params: setClientRolesParamsSchema, + body: { + content: { + "application/json": { + schema: setClientRolesBodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function setClientRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setClientRolesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = setClientRolesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + const { roleIds } = parsedBody.data; + + if (req.user && !req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const uniqueRoleIds = [...new Set(roleIds)]; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + if (client.userId !== null) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot set roles on clients that are associated with a user" + ) + ); + } + + const orgId = client.orgId; + + if (uniqueRoleIds.length > 0) { + const orgRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + inArray(roles.roleId, uniqueRoleIds) + ) + ); + + if (orgRoles.length !== uniqueRoleIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more role IDs are invalid for this organization" + ) + ); + } + } + + if (await isOrgRebuildRateLimited(orgId)) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "Too many concurrent rebuild operations for this organization. Please retry after a moment." + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(clientOrgRoles) + .where( + and( + eq(clientOrgRoles.clientId, clientId), + eq(clientOrgRoles.orgId, orgId) + ) + ); + + if (uniqueRoleIds.length > 0) { + await trx.insert(clientOrgRoles).values( + uniqueRoleIds.map((roleId) => ({ + clientId, + orgId, + roleId + })) + ); + } + }); + + rebuildClientAssociationsFromClient(client).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${clientId} after setting roles: ${e}` + ); + }); + + return response(res, { + data: { clientId, orgId, roleIds: uniqueRoleIds }, + success: true, + error: false, + message: "Client roles set successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0110bf51d..6f2176317 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -29,6 +29,7 @@ import * as misc from "#private/routers/misc"; import * as reKey from "#private/routers/re-key"; import * as approval from "#private/routers/approvals"; import * as user from "#private/routers/user"; +import * as client from "#private/routers/client"; import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; @@ -674,6 +675,36 @@ authenticated.post( user.setUserOrgRoles ); +authenticated.post( + "/client/:clientId/add-role/:roleId", + verifyClientAccess, + verifyRoleAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.addClientRole), + logActionAudit(ActionsEnum.addClientRole), + client.addClientRole +); + +authenticated.delete( + "/client/:clientId/remove-role/:roleId", + verifyClientAccess, + verifyRoleAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.removeClientRole), + logActionAudit(ActionsEnum.removeClientRole), + client.removeClientRole +); + +authenticated.post( + "/client/:clientId/roles", + verifyClientAccess, + verifyRoleAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setClientRoles), + logActionAudit(ActionsEnum.setClientRoles), + client.setClientRoles +); + authenticated.put( "/org/:orgId/site-provisioning-key", verifyValidLicense, diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 814fa8e4a..221d16c31 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -29,11 +29,13 @@ import { verifyApiKeyIdpAccess, verifyApiKeyRoleAccess, verifyApiKeyUserAccess, + verifyApiKeyClientAccess, verifyApiKeySiteProvisioningKeyAccess, verifyApiKeyResourcePolicyAccess, verifyLimits } from "@server/middlewares"; import * as user from "#private/routers/user"; +import * as client from "#private/routers/client"; import { verifyValidSubscription, verifyValidLicense @@ -246,6 +248,36 @@ authenticated.delete( user.removeUserRole ); +authenticated.post( + "/client/:clientId/add-role/:roleId", + verifyApiKeyClientAccess, + verifyApiKeyRoleAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.addClientRole), + logActionAudit(ActionsEnum.addClientRole), + client.addClientRole +); + +authenticated.delete( + "/client/:clientId/remove-role/:roleId", + verifyApiKeyClientAccess, + verifyApiKeyRoleAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.removeClientRole), + logActionAudit(ActionsEnum.removeClientRole), + client.removeClientRole +); + +authenticated.post( + "/client/:clientId/roles", + verifyApiKeyClientAccess, + verifyApiKeyRoleAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setClientRoles), + logActionAudit(ActionsEnum.setClientRoles), + client.setClientRoles +); + authenticated.put( "/org/:orgId/site-provisioning-key", verifyValidLicense, diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index 21b49cf7a..491a014b7 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -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(res, { diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 3a05c41c5..01448a80f 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -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>[0] & { }>; olmUpdateAvailable?: boolean; labels?: Array>; + 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 })) }; }); diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts index a79d11269..23c8988d5 100644 --- a/server/routers/role/deleteRole.ts +++ b/server/routers/role/deleteRole.ts @@ -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, diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx index 0e3d9b09d..8e787b997 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx @@ -34,6 +34,10 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import ActionBanner from "@app/components/ActionBanner"; import { Shield, ShieldOff } from "lucide-react"; +import OrgRolesTagField from "@app/components/OrgRolesTagField"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), @@ -42,14 +46,40 @@ const GeneralFormSchema = z.object({ type GeneralFormValues = z.infer; +const RolesFormSchema = z.object({ + roles: z.array( + z.object({ + id: z.string(), + text: z.string(), + isAdmin: z.boolean().optional() + }) + ) +}); + +type RolesFormValues = z.infer; + export default function GeneralPage() { const t = useTranslations(); const { client, updateClient } = useClientContext(); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + const api = createApiClient({ env }); const [loading, setLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const router = useRouter(); const [, startTransition] = useTransition(); + const [savingRoles, setSavingRoles] = useState(false); + const { isPaidUser } = usePaidStatus(); + const canAssignRoles = isPaidUser(tierMatrix.fullRbac); + + const rolesForm = useForm({ + resolver: zodResolver(RolesFormSchema), + defaultValues: { + roles: (client?.roles ?? []).map((r) => ({ + id: r.roleId.toString(), + text: r.name + })) + } + }); const form = useForm({ resolver: zodResolver(GeneralFormSchema), @@ -113,6 +143,41 @@ export default function GeneralPage() { } } + async function onSubmitRoles(data: RolesFormValues) { + if (!client?.clientId) return; + setSavingRoles(true); + + try { + const roleIds = data.roles.map((r) => parseInt(r.id, 10)); + await api.post(`/client/${client.clientId}/roles`, { roleIds }); + + updateClient({ + roles: data.roles.map((r) => ({ + roleId: parseInt(r.id, 10), + name: r.text + })) + }); + + toast({ + title: t("machineClientRolesUpdated"), + description: t("machineClientRolesUpdatedDescription") + }); + + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("machineClientRolesUpdateFailed"), + description: formatAxiosError( + e, + t("machineClientRolesUpdateError") + ) + }); + } finally { + setSavingRoles(false); + } + } + const handleUnblock = async () => { if (!client?.clientId) return; setIsRefreshing(true); @@ -230,6 +295,56 @@ export default function GeneralPage() { + + {!env.flags.disableEnterpriseFeatures && ( + + + + {t("roles")} + + + {t("machineClientRolesDescription")} + + + + + + + +
+ + + + +
+
+ + + + +
+ )} ); } diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index 066fdc3ea..4e8f57da5 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -5,6 +5,7 @@ import MachineClientsBanner from "@app/components/MachineClientsBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListClientsResponse } from "@server/routers/client"; +import type { ListRolesResponse } from "@server/routers/role/listRoles"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import type { Pagination } from "@server/types/Pagination"; @@ -34,17 +35,37 @@ export default async function ClientsPage(props: ClientsPageProps) { pageSize: 20 }; - try { - const machineRes = await internal.get< - AxiosResponse - >( - `/org/${params.orgId}/clients?${searchParams.toString()}`, - await authCookieHeader() - ); + const cookieHeader = await authCookieHeader(); + + const [machineRes, rolesRes] = await Promise.all([ + internal + .get>( + `/org/${params.orgId}/clients?${searchParams.toString()}`, + cookieHeader + ) + .catch(() => {}), + internal + .get>( + `/org/${params.orgId}/roles?pageSize=500&page=1`, + cookieHeader + ) + .catch(() => {}) + ]); + + if (machineRes && machineRes.status === 200) { const responseData = machineRes.data.data; machineClients = responseData.clients; pagination = responseData.pagination; - } catch (e) {} + } + + const orgRoles = + rolesRes && rolesRes.status === 200 + ? (rolesRes.data.data.roles ?? []) + : []; + const roleFilterOptions = orgRoles.map((r) => ({ + value: String(r.roleId), + label: r.name + })); function formatSize(mb: number): string { if (mb >= 1024 * 1024) { @@ -77,7 +98,8 @@ export default async function ClientsPage(props: ClientsPageProps) { archived: client.archived || false, blocked: client.blocked || false, approvalState: client.approvalState ?? "approved", - labels: client.labels ?? [] + labels: client.labels ?? [], + roleLabels: (client.roles ?? []).map((r) => r.name) }; }; @@ -96,6 +118,7 @@ export default async function ClientsPage(props: ClientsPageProps) { machineClients={machineClientRows} orgId={params.orgId} rowCount={pagination.total} + roleFilterOptions={roleFilterOptions} pagination={{ pageIndex: pagination.page - 1, pageSize: pagination.pageSize diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 4143b37eb..2ba7525b0 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -32,6 +32,7 @@ import { useRouter } from "next/navigation"; import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import { ColumnMultiFilterButton } from "./ColumnMultiFilterButton"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { Badge } from "./ui/badge"; @@ -43,6 +44,7 @@ import { import { useQuery } from "@tanstack/react-query"; import semver from "semver"; import { InfoPopup } from "./ui/info-popup"; +import UserRoleBadges from "./UserRoleBadges"; export type ClientRow = { id: number; @@ -68,20 +70,25 @@ export type ClientRow = { name: string; color: string; }>; + roleLabels: string[]; }; +type FilterOption = { value: string; label: string }; + type ClientTableProps = { machineClients: ClientRow[]; orgId: string; pagination: PaginationState; rowCount: number; + roleFilterOptions: FilterOption[]; }; export default function MachineClientsTable({ machineClients, orgId, pagination, - rowCount + rowCount, + roleFilterOptions }: ClientTableProps) { const router = useRouter(); @@ -437,6 +444,27 @@ export default function MachineClientsTable({ friendlyName: t("address"), header: () => {t("address")} }, + { + id: "role", + accessorFn: (row) => row.roleLabels.join(", "), + friendlyName: t("roles"), + header: () => ( + + handleFilterChange("role_id", values) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("roles")} + className="p-3" + /> + ), + cell: ({ row }) => ( + + ) + }, { id: "labels", accessorKey: "labels", @@ -540,7 +568,7 @@ export default function MachineClientsTable({ } return baseColumns; - }, [hasRowsWithoutUserId, orgId, t, searchParams]); + }, [hasRowsWithoutUserId, orgId, t, searchParams, roleFilterOptions]); function handleFilterChange( column: string, diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 64e86c39e..9d44562a2 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -101,7 +101,10 @@ function getActionsCategories(root: boolean) { [t("actionUpdateRole")]: "updateRole", [t("actionListAllowedRoleResources")]: "listRoleResources", [t("actionAddUserRole")]: "addUserRole", - [t("actionRemoveUserRole")]: "removeUserRole" + [t("actionRemoveUserRole")]: "removeUserRole", + [t("actionAddClientRole")]: "addClientRole", + [t("actionRemoveClientRole")]: "removeClientRole", + [t("actionSetClientRoles")]: "setClientRoles" }, "Access Token": { [t("actionGenerateAccessToken")]: "generateAccessToken",