From 76d54b2d0f20fb99863f02b97b6bbfe8b9ffe0dc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 6 Nov 2025 21:25:20 -0800 Subject: [PATCH] add add/remove user/roles to siteResources/resources to integration api --- server/index.ts | 2 + server/lib/blueprints/proxyResources.ts | 8 +- server/lib/telemetry.ts | 9 +- .../verifyApiKeySetResourceUsers.ts | 14 +- .../verifyApiKeySiteResourceAccess.ts | 17 +- .../middlewares/verifySiteResourceAccess.ts | 1 - server/routers/integration.ts | 72 ++++++++ server/routers/org/getOrgOverview.ts | 2 +- server/routers/resource/addRoleToResource.ts | 161 +++++++++++++++++ server/routers/resource/addUserToResource.ts | 130 +++++++++++++ server/routers/resource/index.ts | 4 + .../resource/removeRoleFromResource.ts | 166 +++++++++++++++++ .../resource/removeUserFromResource.ts | 136 ++++++++++++++ server/routers/resource/setResourceRoles.ts | 48 +++-- .../siteResource/addRoleToSiteResource.ts | 166 +++++++++++++++++ .../siteResource/addUserToSiteResource.ts | 135 ++++++++++++++ server/routers/siteResource/index.ts | 4 + .../removeRoleFromSiteResource.ts | 171 ++++++++++++++++++ .../removeUserFromSiteResource.ts | 141 +++++++++++++++ .../siteResource/setSiteResourceRoles.ts | 48 +++-- 20 files changed, 1370 insertions(+), 65 deletions(-) create mode 100644 server/routers/resource/addRoleToResource.ts create mode 100644 server/routers/resource/addUserToResource.ts create mode 100644 server/routers/resource/removeRoleFromResource.ts create mode 100644 server/routers/resource/removeUserFromResource.ts create mode 100644 server/routers/siteResource/addRoleToSiteResource.ts create mode 100644 server/routers/siteResource/addUserToSiteResource.ts create mode 100644 server/routers/siteResource/removeRoleFromSiteResource.ts create mode 100644 server/routers/siteResource/removeUserFromSiteResource.ts diff --git a/server/index.ts b/server/index.ts index daa4b7d3..012a4bbb 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,7 @@ import { ApiKeyOrg, RemoteExitNode, Session, + SiteResource, User, UserOrg } from "@server/db"; @@ -77,6 +78,7 @@ declare global { userOrgId?: string; userOrgIds?: string[]; remoteExitNode?: RemoteExitNode; + siteResource?: SiteResource; } } } diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 323e6a6a..c0e5ba40 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -780,10 +780,6 @@ async function syncRoleResources( .where(eq(roleResources.resourceId, resourceId)); for (const roleName of ssoRoles) { - if (roleName === "Admin") { - continue; // never add admin access - } - const [role] = await trx .select() .from(roles) @@ -794,6 +790,10 @@ async function syncRoleResources( throw new Error(`Role not found: ${roleName} in org ${orgId}`); } + if (role.isAdmin) { + continue; // never add admin access + } + const existingRoleResource = existingRoleResources.find( (rr) => rr.roleId === role.roleId ); diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index d3b0b9d6..f2220017 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -4,7 +4,7 @@ import { getHostMeta } from "./hostMeta"; import logger from "@server/logger"; import { apiKeys, db, roles } from "@server/db"; import { sites, users, orgs, resources, clients, idp } from "@server/db"; -import { eq, count, notInArray } from "drizzle-orm"; +import { eq, count, notInArray, and } from "drizzle-orm"; import { APP_VERSION } from "./consts"; import crypto from "crypto"; import { UserType } from "@server/types/UserTypes"; @@ -113,7 +113,12 @@ class TelemetryClient { const [customRoles] = await db .select({ count: count() }) .from(roles) - .where(notInArray(roles.name, ["Admin", "Member"])); + .where( + and( + eq(roles.isAdmin, false), + notInArray(roles.name, ["Member"]) + ) + ); const adminUsers = await db .select({ email: users.email }) diff --git a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts index 51a8f3fc..db73d134 100644 --- a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts +++ b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts @@ -11,7 +11,9 @@ export async function verifyApiKeySetResourceUsers( next: NextFunction ) { const apiKey = req.apiKey; - const userIds = req.body.userIds; + const singleUserId = req.params.userId || req.body.userId || req.query.userId; + const { userIds } = req.body; + const allUserIds = userIds || (singleUserId ? [singleUserId] : []); if (!apiKey) { return next( @@ -33,11 +35,7 @@ export async function verifyApiKeySetResourceUsers( ); } - if (!userIds) { - return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); - } - - if (userIds.length === 0) { + if (allUserIds.length === 0) { return next(); } @@ -48,12 +46,12 @@ export async function verifyApiKeySetResourceUsers( .from(userOrgs) .where( and( - inArray(userOrgs.userId, userIds), + inArray(userOrgs.userId, allUserIds), eq(userOrgs.orgId, orgId) ) ); - if (userOrgsData.length !== userIds.length) { + if (userOrgsData.length !== allUserIds.length) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/middlewares/integration/verifyApiKeySiteResourceAccess.ts b/server/middlewares/integration/verifyApiKeySiteResourceAccess.ts index cba94cd1..fb3d8287 100644 --- a/server/middlewares/integration/verifyApiKeySiteResourceAccess.ts +++ b/server/middlewares/integration/verifyApiKeySiteResourceAccess.ts @@ -13,8 +13,6 @@ export async function verifyApiKeySiteResourceAccess( try { const apiKey = req.apiKey; const siteResourceId = parseInt(req.params.siteResourceId); - const siteId = parseInt(req.params.siteId); - const orgId = req.params.orgId; if (!apiKey) { return next( @@ -22,11 +20,11 @@ export async function verifyApiKeySiteResourceAccess( ); } - if (!siteResourceId || !siteId || !orgId) { + if (!siteResourceId) { return next( createHttpError( HttpCode.BAD_REQUEST, - "Missing required parameters" + "Missing siteResourceId parameter" ) ); } @@ -41,9 +39,7 @@ export async function verifyApiKeySiteResourceAccess( .select() .from(siteResources) .where(and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) + eq(siteResources.siteResourceId, siteResourceId) )) .limit(1); @@ -64,11 +60,11 @@ export async function verifyApiKeySiteResourceAccess( .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), - eq(apiKeyOrg.orgId, orgId) + eq(apiKeyOrg.orgId, siteResource.orgId) ) ) .limit(1); - + if (apiKeyOrgRes.length === 0) { return next( createHttpError( @@ -77,12 +73,11 @@ export async function verifyApiKeySiteResourceAccess( ) ); } - + req.apiKeyOrg = apiKeyOrgRes[0]; } // Attach the siteResource to the request for use in the next middleware/route - // @ts-ignore - Extending Request type req.siteResource = siteResource; return next(); diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index 5fad3f9e..82b0a3ce 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -95,7 +95,6 @@ export async function verifySiteResourceAccess( req.userOrgId = siteResource.orgId; // Attach the siteResource to the request for use in the next middleware/route - // @ts-ignore - Extending Request type req.siteResource = siteResource; const roleResourceAccess = await db diff --git a/server/routers/integration.ts b/server/routers/integration.ts index a70b4e32..f2de363a 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -229,6 +229,42 @@ authenticated.post( siteResource.setSiteResourceUsers ); +authenticated.post( + "/site-resource/:siteResourceId/roles/add", + verifyApiKeySiteResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + siteResource.addRoleToSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/roles/remove", + verifyApiKeySiteResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + siteResource.removeRoleFromSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/users/add", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.addUserToSiteResource +); + +authenticated.post( + "/site-resource/:siteResourceId/users/remove", + verifyApiKeySiteResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.removeUserFromSiteResource +); + authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, @@ -444,6 +480,42 @@ authenticated.post( resource.setResourceUsers ); +authenticated.post( + "/resource/:resourceId/roles/add", + verifyApiKeyResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + resource.addRoleToResource +); + +authenticated.post( + "/resource/:resourceId/roles/remove", + verifyApiKeyResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + logActionAudit(ActionsEnum.setResourceRoles), + resource.removeRoleFromResource +); + +authenticated.post( + "/resource/:resourceId/users/add", + verifyApiKeyResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + resource.addUserToResource +); + +authenticated.post( + "/resource/:resourceId/users/remove", + verifyApiKeyResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + resource.removeUserFromResource +); + authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, diff --git a/server/routers/org/getOrgOverview.ts b/server/routers/org/getOrgOverview.ts index 67a14464..c19b0a57 100644 --- a/server/routers/org/getOrgOverview.ts +++ b/server/routers/org/getOrgOverview.ts @@ -132,7 +132,7 @@ export async function getOrgOverview( numSites, numUsers, numResources, - isAdmin: role.name === "Admin", + isAdmin: role.isAdmin || false, isOwner: req.userOrg?.isOwner || false }, success: true, diff --git a/server/routers/resource/addRoleToResource.ts b/server/routers/resource/addRoleToResource.ts new file mode 100644 index 00000000..c29f2757 --- /dev/null +++ b/server/routers/resource/addRoleToResource.ts @@ -0,0 +1,161 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { roleResources, roles } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const addRoleToResourceBodySchema = z + .object({ + roleId: z.number().int().positive() + }) + .strict(); + +const addRoleToResourceParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/roles/add", + description: "Add a single role to a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: addRoleToResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addRoleToResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function addRoleToResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addRoleToResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleId } = parsedBody.data; + + const parsedParams = addRoleToResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + // get the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + // verify the role exists and belongs to the same org + const [role] = await db + .select() + .from(roles) + .where( + and( + eq(roles.roleId, roleId), + eq(roles.orgId, resource.orgId) + ) + ) + .limit(1); + + if (!role) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the same organization" + ) + ); + } + + // Check if the role is an admin role + if (role.isAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to resources" + ) + ); + } + + // Check if role already exists in resource + const existingEntry = await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Role already assigned to resource" + ) + ); + } + + await db.insert(roleResources).values({ + roleId, + resourceId + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Role added to resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/resource/addUserToResource.ts b/server/routers/resource/addUserToResource.ts new file mode 100644 index 00000000..6dbfe086 --- /dev/null +++ b/server/routers/resource/addUserToResource.ts @@ -0,0 +1,130 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { userResources } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const addUserToResourceBodySchema = z + .object({ + userId: z.string() + }) + .strict(); + +const addUserToResourceParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/users/add", + description: "Add a single user to a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: addUserToResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addUserToResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function addUserToResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addUserToResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId } = parsedBody.data; + + const parsedParams = addUserToResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + // get the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + // Check if user already exists in resource + const existingEntry = await db + .select() + .from(userResources) + .where( + and( + eq(userResources.resourceId, resourceId), + eq(userResources.userId, userId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "User already assigned to resource" + ) + ); + } + + await db.insert(userResources).values({ + userId, + resourceId + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "User added to resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index d1c7011d..e6911ffc 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -25,3 +25,7 @@ export * from "./getUserResources"; export * from "./setResourceHeaderAuth"; export * from "./addEmailToResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./addRoleToResource"; +export * from "./removeRoleFromResource"; +export * from "./addUserToResource"; +export * from "./removeUserFromResource"; diff --git a/server/routers/resource/removeRoleFromResource.ts b/server/routers/resource/removeRoleFromResource.ts new file mode 100644 index 00000000..cb44ac4a --- /dev/null +++ b/server/routers/resource/removeRoleFromResource.ts @@ -0,0 +1,166 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { roleResources, roles } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const removeRoleFromResourceBodySchema = z + .object({ + roleId: z.number().int().positive() + }) + .strict(); + +const removeRoleFromResourceParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/roles/remove", + description: "Remove a single role from a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: removeRoleFromResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeRoleFromResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function removeRoleFromResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeRoleFromResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleId } = parsedBody.data; + + const parsedParams = removeRoleFromResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + // get the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + // Check if the role is an admin role + const [roleToCheck] = await db + .select() + .from(roles) + .where( + and( + eq(roles.roleId, roleId), + eq(roles.orgId, resource.orgId) + ) + ) + .limit(1); + + if (!roleToCheck) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the same organization" + ) + ); + } + + if (roleToCheck.isAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be removed from resources" + ) + ); + } + + // Check if role exists in resource + const existingEntry = await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found in resource" + ) + ); + } + + await db + .delete(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + eq(roleResources.roleId, roleId) + ) + ); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Role removed from resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/resource/removeUserFromResource.ts b/server/routers/resource/removeUserFromResource.ts new file mode 100644 index 00000000..8dce7e48 --- /dev/null +++ b/server/routers/resource/removeUserFromResource.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, resources } from "@server/db"; +import { userResources } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const removeUserFromResourceBodySchema = z + .object({ + userId: z.string() + }) + .strict(); + +const removeUserFromResourceParamsSchema = z + .object({ + resourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/users/remove", + description: "Remove a single user from a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: removeUserFromResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeUserFromResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function removeUserFromResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeUserFromResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId } = parsedBody.data; + + const parsedParams = removeUserFromResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + + // get the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + // Check if user exists in resource + const existingEntry = await db + .select() + .from(userResources) + .where( + and( + eq(userResources.resourceId, resourceId), + eq(userResources.userId, userId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in resource" + ) + ); + } + + await db + .delete(userResources) + .where( + and( + eq(userResources.resourceId, resourceId), + eq(userResources.userId, userId) + ) + ); + + return response(res, { + data: {}, + success: true, + error: false, + message: "User removed from resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 7ea76d21..380aad74 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -7,7 +7,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { eq, and, ne } from "drizzle-orm"; +import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const setResourceRolesBodySchema = z @@ -90,28 +90,20 @@ export async function setResourceRoles( ); } - // get this org's admin role - const adminRole = await db + // Check if any of the roleIds are admin roles + const rolesToCheck = await db .select() .from(roles) .where( and( - eq(roles.name, "Admin"), + inArray(roles.roleId, roleIds), eq(roles.orgId, resource.orgId) ) - ) - .limit(1); - - if (!adminRole.length) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Admin role not found" - ) ); - } - if (roleIds.includes(adminRole[0].roleId)) { + const hasAdminRole = rolesToCheck.some((role) => role.isAdmin); + + if (hasAdminRole) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -120,13 +112,31 @@ export async function setResourceRoles( ); } - await db.transaction(async (trx) => { - await trx.delete(roleResources).where( + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await db + .select() + .from(roles) + .where( and( - eq(roleResources.resourceId, resourceId), - ne(roleResources.roleId, adminRole[0].roleId) // delete all but the admin role + eq(roles.isAdmin, true), + eq(roles.orgId, resource.orgId) ) ); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + await db.transaction(async (trx) => { + if (adminRoleIds.length > 0) { + await trx.delete(roleResources).where( + and( + eq(roleResources.resourceId, resourceId), + ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx.delete(roleResources).where( + eq(roleResources.resourceId, resourceId) + ); + } const newRoleResources = await Promise.all( roleIds.map((roleId) => diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts new file mode 100644 index 00000000..2a5c1a7e --- /dev/null +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -0,0 +1,166 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { roleSiteResources, roles } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildSiteClientAssociations } from "@server/lib/rebuildSiteClientAssociations"; + +const addRoleToSiteResourceBodySchema = z + .object({ + roleId: z.number().int().positive() + }) + .strict(); + +const addRoleToSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/roles/add", + description: "Add a single role to a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: addRoleToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addRoleToSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function addRoleToSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addRoleToSiteResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleId } = parsedBody.data; + + const parsedParams = addRoleToSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // verify the role exists and belongs to the same org + const [role] = await db + .select() + .from(roles) + .where( + and( + eq(roles.roleId, roleId), + eq(roles.orgId, siteResource.orgId) + ) + ) + .limit(1); + + if (!role) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the same organization" + ) + ); + } + + // Check if the role is an admin role + if (role.isAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to site resources" + ) + ); + } + + // Check if role already exists in site resource + const existingEntry = await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + eq(roleSiteResources.roleId, roleId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Role already assigned to site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx.insert(roleSiteResources).values({ + roleId, + siteResourceId + }); + + await rebuildSiteClientAssociations(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Role added to site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts new file mode 100644 index 00000000..279f5350 --- /dev/null +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -0,0 +1,135 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { userSiteResources } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildSiteClientAssociations } from "@server/lib/rebuildSiteClientAssociations"; + +const addUserToSiteResourceBodySchema = z + .object({ + userId: z.string() + }) + .strict(); + +const addUserToSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/users/add", + description: "Add a single user to a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: addUserToSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: addUserToSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function addUserToSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = addUserToSiteResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId } = parsedBody.data; + + const parsedParams = addUserToSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // Check if user already exists in site resource + const existingEntry = await db + .select() + .from(userSiteResources) + .where( + and( + eq(userSiteResources.siteResourceId, siteResourceId), + eq(userSiteResources.userId, userId) + ) + ); + + if (existingEntry.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "User already assigned to site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx.insert(userSiteResources).values({ + userId, + siteResourceId + }); + + await rebuildSiteClientAssociations(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "User added to site resource successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 7fde4187..5d9eeb09 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -8,3 +8,7 @@ export * from "./listSiteResourceRoles"; export * from "./listSiteResourceUsers"; export * from "./setSiteResourceRoles"; export * from "./setSiteResourceUsers"; +export * from "./addRoleToSiteResource"; +export * from "./removeRoleFromSiteResource"; +export * from "./addUserToSiteResource"; +export * from "./removeUserFromSiteResource"; diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts new file mode 100644 index 00000000..2d8f1221 --- /dev/null +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -0,0 +1,171 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { roleSiteResources, roles } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildSiteClientAssociations } from "@server/lib/rebuildSiteClientAssociations"; + +const removeRoleFromSiteResourceBodySchema = z + .object({ + roleId: z.number().int().positive() + }) + .strict(); + +const removeRoleFromSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/roles/remove", + description: "Remove a single role from a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: removeRoleFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeRoleFromSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function removeRoleFromSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeRoleFromSiteResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleId } = parsedBody.data; + + const parsedParams = removeRoleFromSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // Check if the role is an admin role + const [roleToCheck] = await db + .select() + .from(roles) + .where( + and( + eq(roles.roleId, roleId), + eq(roles.orgId, siteResource.orgId) + ) + ) + .limit(1); + + if (!roleToCheck) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the same organization" + ) + ); + } + + if (roleToCheck.isAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be removed from site resources" + ) + ); + } + + // Check if role exists in site resource + const existingEntry = await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + eq(roleSiteResources.roleId, roleId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found in site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + eq(roleSiteResources.roleId, roleId) + ) + ); + + await rebuildSiteClientAssociations(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Role removed from site resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts new file mode 100644 index 00000000..5463e6a1 --- /dev/null +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -0,0 +1,141 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, siteResources } from "@server/db"; +import { userSiteResources } from "@server/db"; +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 { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildSiteClientAssociations } from "@server/lib/rebuildSiteClientAssociations"; + +const removeUserFromSiteResourceBodySchema = z + .object({ + userId: z.string() + }) + .strict(); + +const removeUserFromSiteResourceParamsSchema = z + .object({ + siteResourceId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/site-resource/{siteResourceId}/users/remove", + description: "Remove a single user from a site resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: removeUserFromSiteResourceParamsSchema, + body: { + content: { + "application/json": { + schema: removeUserFromSiteResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function removeUserFromSiteResource( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = removeUserFromSiteResourceBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userId } = parsedBody.data; + + const parsedParams = removeUserFromSiteResourceParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteResourceId } = parsedParams.data; + + // get the site resource + const [siteResource] = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)) + .limit(1); + + if (!siteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + // Check if user exists in site resource + const existingEntry = await db + .select() + .from(userSiteResources) + .where( + and( + eq(userSiteResources.siteResourceId, siteResourceId), + eq(userSiteResources.userId, userId) + ) + ); + + if (existingEntry.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in site resource" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(userSiteResources) + .where( + and( + eq(userSiteResources.siteResourceId, siteResourceId), + eq(userSiteResources.userId, userId) + ) + ); + + await rebuildSiteClientAssociations(siteResource, trx); + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "User removed from site resource successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} + diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index 3be0ee11..3b829d1f 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -7,7 +7,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { eq, and, ne } from "drizzle-orm"; +import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildSiteClientAssociations } from "@server/lib/rebuildSiteClientAssociations"; @@ -93,28 +93,20 @@ export async function setSiteResourceRoles( ); } - // get this org's admin role - const adminRole = await db + // Check if any of the roleIds are admin roles + const rolesToCheck = await db .select() .from(roles) .where( and( - eq(roles.name, "Admin"), + inArray(roles.roleId, roleIds), eq(roles.orgId, siteResource.orgId) ) - ) - .limit(1); - - if (!adminRole.length) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Admin role not found" - ) ); - } - if (roleIds.includes(adminRole[0].roleId)) { + const hasAdminRole = rolesToCheck.some((role) => role.isAdmin); + + if (hasAdminRole) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -123,13 +115,31 @@ export async function setSiteResourceRoles( ); } - await db.transaction(async (trx) => { - await trx.delete(roleSiteResources).where( + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await db + .select() + .from(roles) + .where( and( - eq(roleSiteResources.siteResourceId, siteResourceId), - ne(roleSiteResources.roleId, adminRole[0].roleId) // delete all but the admin role + eq(roles.isAdmin, true), + eq(roles.orgId, siteResource.orgId) ) ); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + await db.transaction(async (trx) => { + if (adminRoleIds.length > 0) { + await trx.delete(roleSiteResources).where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx.delete(roleSiteResources).where( + eq(roleSiteResources.siteResourceId, siteResourceId) + ); + } await Promise.all( roleIds.map((roleId) =>