From ee21e1faa7d372e998ccec39a0df77ac163d16ee Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 18 Feb 2026 05:08:42 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20list=20authentication=20items=20?= =?UTF-8?q?from=20policy=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/auth/actions.ts | 4 + server/middlewares/index.ts | 1 + .../middlewares/verifyResourcePolicyAccess.ts | 125 +++++++++++++ server/routers/external.ts | 36 +++- server/routers/resource/index.ts | 4 + .../resource/listResourcePolicyRoles.ts | 80 +++++++++ .../resource/listResourcePolicyUsers.ts | 85 +++++++++ .../resource/setResourcePolicyRoles.ts | 165 ++++++++++++++++++ .../resource/setResourcePolicyUsers.ts | 124 +++++++++++++ 9 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 server/middlewares/verifyResourcePolicyAccess.ts create mode 100644 server/routers/resource/listResourcePolicyRoles.ts create mode 100644 server/routers/resource/listResourcePolicyUsers.ts create mode 100644 server/routers/resource/setResourcePolicyRoles.ts create mode 100644 server/routers/resource/setResourcePolicyUsers.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 01748b6b9..9c94c6a6a 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -136,6 +136,10 @@ export enum ActionsEnum { createResourcePolicies = "createResourcePolicies", updateResourcePolicies = "updateResourcePolicies", deleteResourcePolicies = "deleteResourcePolicies", + listResourcePolicyRoles = "listResourcePolicyRoles", + setResourcePolicyRoles = "setResourcePolicyRoles", + listResourcePolicyUsers = "listResourcePolicyUsers", + setResourcePolicyUsers = "setResourcePolicyUsers", } export async function checkUserActionPermission( diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 6437c90e2..9ea190113 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -30,3 +30,4 @@ export * from "./verifySiteResourceAccess"; export * from "./logActionAudit"; export * from "./verifyOlmAccess"; export * from "./verifyLimits"; +export * from "./verifyResourcePolicyAccess"; diff --git a/server/middlewares/verifyResourcePolicyAccess.ts b/server/middlewares/verifyResourcePolicyAccess.ts new file mode 100644 index 000000000..83eb69d7f --- /dev/null +++ b/server/middlewares/verifyResourcePolicyAccess.ts @@ -0,0 +1,125 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resourcePolicies, userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; + +export async function verifyResourcePolicyAccess( + req: Request, + res: Response, + next: NextFunction +) { + const userId = req.user!.userId; + const resourcePolicyIdStr = + req.params?.resourcePolicyId || + req.body?.resourcePolicyId || + req.query?.resourcePolicyId; + const niceId = + req.params?.niceId || req.body?.niceId || req.query?.niceId; + const orgId = + req.params?.orgId || req.body?.orgId || req.query?.orgId; + + try { + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + let policy: typeof resourcePolicies.$inferSelect | null = null; + + if (orgId && niceId) { + const [policyRes] = await db + .select() + .from(resourcePolicies) + .where( + and( + eq(resourcePolicies.niceId, niceId), + eq(resourcePolicies.orgId, orgId) + ) + ) + .limit(1); + policy = policyRes ?? null; + } else { + const resourcePolicyId = parseInt(resourcePolicyIdStr); + if (isNaN(resourcePolicyId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid resource policy ID" + ) + ); + } + const [policyRes] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + policy = policyRes ?? null; + } + + if (!policy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource policy with ID ${resourcePolicyIdStr ?? niceId} not found` + ) + ); + } + + if (!req.userOrg) { + const userOrgRes = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, policy.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRes[0]; + } + + if (!req.userOrg || req.userOrg.orgId !== policy.orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgRoleId = req.userOrg.roleId; + req.userOrgId = policy.orgId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying resource policy access" + ) + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 52aaa81e9..c69fdacc5 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -42,7 +42,8 @@ import { verifyUserIsOrgOwner, verifySiteResourceAccess, verifyOlmAccess, - verifyLimits + verifyLimits, + verifyResourcePolicyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; @@ -676,6 +677,39 @@ authenticated.post( resource.setResourceUsers ); +authenticated.get( + "/resource-policy/:resourcePolicyId/roles", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.listResourcePolicyRoles), + resource.listResourcePolicyRoles +); + +authenticated.get( + "/resource-policy/:resourcePolicyId/users", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.listResourcePolicyUsers), + resource.listResourcePolicyUsers +); + +authenticated.post( + "/resource-policy/:resourcePolicyId/roles", + verifyResourcePolicyAccess, + verifyRoleAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyRoles), + logActionAudit(ActionsEnum.setResourcePolicyRoles), + resource.setResourcePolicyRoles +); + +authenticated.post( + "/resource-policy/:resourcePolicyId/users", + verifyResourcePolicyAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.setResourcePolicyUsers), + logActionAudit(ActionsEnum.setResourcePolicyUsers), + resource.setResourcePolicyUsers +); + authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 3ada13d85..3a6ff49f6 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -31,3 +31,7 @@ export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./listResourcePolicyRoles"; +export * from "./listResourcePolicyUsers"; +export * from "./setResourcePolicyRoles"; +export * from "./setResourcePolicyUsers"; diff --git a/server/routers/resource/listResourcePolicyRoles.ts b/server/routers/resource/listResourcePolicyRoles.ts new file mode 100644 index 000000000..187e46d6b --- /dev/null +++ b/server/routers/resource/listResourcePolicyRoles.ts @@ -0,0 +1,80 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { roleResources, 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"; + +const listResourcePolicyRolesSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +async function query(resourcePolicyId: number) { + return await db + .selectDistinct({ + roleId: roles.roleId, + name: roles.name, + description: roles.description, + isAdmin: roles.isAdmin + }) + .from(roleResources) + .innerJoin(roles, eq(roleResources.roleId, roles.roleId)) + .where(eq(roleResources.resourcePolicyId, resourcePolicyId)); +} + +export type ListResourcePolicyRolesResponse = { + roles: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/resource-policy/{resourcePolicyId}/roles", + description: "List all roles for a resource policy.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: listResourcePolicyRolesSchema + }, + responses: {} +}); + +export async function listResourcePolicyRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listResourcePolicyRolesSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + + const policyRolesList = await query(resourcePolicyId); + + return response(res, { + data: { + roles: policyRolesList + }, + success: true, + error: false, + message: "Resource policy roles retrieved 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/listResourcePolicyUsers.ts b/server/routers/resource/listResourcePolicyUsers.ts new file mode 100644 index 000000000..a67366bb8 --- /dev/null +++ b/server/routers/resource/listResourcePolicyUsers.ts @@ -0,0 +1,85 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { idp, userResources, users } 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"; + +const listResourcePolicyUsersSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +async function queryUsers(resourcePolicyId: number) { + return await db + .selectDistinct({ + userId: userResources.userId, + username: users.username, + type: users.type, + idpName: idp.name, + idpId: users.idpId, + email: users.email + }) + .from(userResources) + .innerJoin(users, eq(userResources.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .where(eq(userResources.resourcePolicyId, resourcePolicyId)); +} + +export type ListResourcePolicyUsersResponse = { + users: NonNullable>>; +}; + +registry.registerPath({ + method: "get", + path: "/resource-policy/{resourcePolicyId}/users", + description: "List all users for a resource policy.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: listResourcePolicyUsersSchema + }, + responses: {} +}); + +export async function listResourcePolicyUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = listResourcePolicyUsersSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + + const policyUsersList = await queryUsers(resourcePolicyId); + + return response(res, { + data: { + users: policyUsersList + }, + success: true, + error: false, + message: "Resource policy users retrieved 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/setResourcePolicyRoles.ts b/server/routers/resource/setResourcePolicyRoles.ts new file mode 100644 index 000000000..2a5134f4e --- /dev/null +++ b/server/routers/resource/setResourcePolicyRoles.ts @@ -0,0 +1,165 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourcePolicies, resources, 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, ne, inArray } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourcePolicyRolesBodySchema = z.strictObject({ + roleIds: z.array(z.int().positive()) +}); + +const setResourcePolicyRolesParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/resource-policy/{resourcePolicyId}/roles", + description: + "Set roles for a resource policy. This will replace all existing roles across all resources under this policy.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: setResourcePolicyRolesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyRolesBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setResourcePolicyRolesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { roleIds } = parsedBody.data; + + const parsedParams = setResourcePolicyRolesParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + // Check if any of the roleIds are admin roles + const rolesToCheck = await db + .select() + .from(roles) + .where( + and( + inArray(roles.roleId, roleIds), + eq(roles.orgId, policy.orgId) + ) + ); + + const hasAdminRole = rolesToCheck.some((role) => role.isAdmin); + if (hasAdminRole) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Admin role cannot be assigned to resource policies" + ) + ); + } + + // Get admin role IDs for this org to exclude from deletion + const adminRoles = await db + .select() + .from(roles) + .where( + and(eq(roles.isAdmin, true), eq(roles.orgId, policy.orgId)) + ); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + // Get all resources under this policy + const policyResources = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.resourcePolicyId, resourcePolicyId)); + + await db.transaction(async (trx) => { + // Delete existing role associations for this policy (excluding admin roles) + if (adminRoleIds.length > 0) { + await trx.delete(roleResources).where( + and( + eq(roleResources.resourcePolicyId, resourcePolicyId), + ne(roleResources.roleId, adminRoleIds[0]) + ) + ); + } else { + await trx + .delete(roleResources) + .where( + eq(roleResources.resourcePolicyId, resourcePolicyId) + ); + } + + // Insert new role associations for each resource under the policy + if (roleIds.length > 0 && policyResources.length > 0) { + await Promise.all( + policyResources.flatMap(({ resourceId }) => + roleIds.map((roleId) => + trx + .insert(roleResources) + .values({ roleId, resourceId, resourcePolicyId }) + .returning() + ) + ) + ); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Roles set for resource policy 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/setResourcePolicyUsers.ts b/server/routers/resource/setResourcePolicyUsers.ts new file mode 100644 index 000000000..8f18d93c8 --- /dev/null +++ b/server/routers/resource/setResourcePolicyUsers.ts @@ -0,0 +1,124 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resourcePolicies, resources, 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 } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const setResourcePolicyUsersBodySchema = z.strictObject({ + userIds: z.array(z.string()) +}); + +const setResourcePolicyUsersParamsSchema = z.strictObject({ + resourcePolicyId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/resource-policy/{resourcePolicyId}/users", + description: + "Set users for a resource policy. This will replace all existing users across all resources under this policy.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: setResourcePolicyUsersParamsSchema, + body: { + content: { + "application/json": { + schema: setResourcePolicyUsersBodySchema + } + } + } + }, + responses: {} +}); + +export async function setResourcePolicyUsers( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = setResourcePolicyUsersBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { userIds } = parsedBody.data; + + const parsedParams = setResourcePolicyUsersParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourcePolicyId } = parsedParams.data; + + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + + if (!policy) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource policy not found") + ); + } + + // Get all resources under this policy + const policyResources = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.resourcePolicyId, resourcePolicyId)); + + await db.transaction(async (trx) => { + // Delete existing user associations for this policy + await trx + .delete(userResources) + .where(eq(userResources.resourcePolicyId, resourcePolicyId)); + + // Insert new user associations for each resource under the policy + if (userIds.length > 0 && policyResources.length > 0) { + await Promise.all( + policyResources.flatMap(({ resourceId }) => + userIds.map((userId) => + trx + .insert(userResources) + .values({ userId, resourceId, resourcePolicyId }) + .returning() + ) + ) + ); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Users set for resource policy successfully", + status: HttpCode.CREATED + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +}