diff --git a/messages/en-US.json b/messages/en-US.json index fc1523687..f310c7aeb 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", 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 a4210ab8e..9ff07e6d1 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -807,6 +807,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", { @@ -2009,6 +2030,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 a2c2339aa..073f4251f 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1094,6 +1094,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() @@ -2052,6 +2073,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 02b84c993..379e91600 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1,5 +1,6 @@ import { Client, + clientOrgRoles, clients, clientSiteResources, clientSiteResourcesAssociationsCache, @@ -257,9 +258,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 @@ -286,7 +308,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 { @@ -2150,11 +2172,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 @@ -2175,7 +2202,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 @@ -2199,8 +2239,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( @@ -2211,32 +2250,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 = @@ -2946,86 +2998,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/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/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",