From ebfaf499ad9e0dc3a3fec4f43b86f6b5a655720c Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 19 Apr 2026 22:15:12 -0500 Subject: [PATCH] fix: tighten member access validation and lookup costs Co-Authored-By: Paperclip --- packages/shared/src/validators/access.ts | 2 +- server/src/routes/access.ts | 33 +++++++++++++++++++++--- server/src/routes/user-profiles.ts | 3 ++- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index dbba3a8599e..e104bdec7ca 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -121,7 +121,7 @@ export const archiveCompanyMemberSchema = z.object({ reassignment: z .object({ assigneeAgentId: z.string().uuid().optional().nullable(), - assigneeUserId: z.string().optional().nullable(), + assigneeUserId: z.string().uuid().optional().nullable(), }) .optional() .nullable(), diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 459a3bd6da7..979f8ef4f25 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -18,6 +18,7 @@ import { companies, companyLogos, companyMemberships, + instanceUserRoles, invites, joinRequests, principalPermissionGrants, @@ -1075,11 +1076,18 @@ async function getProtectedMemberReason( access: ReturnType, companyId: string, member: { principalId: string; principalType: string; membershipRole: string | null }, + opts?: { + actorRole?: HumanCompanyMembershipRole | null; + instanceAdminUserIds?: ReadonlySet; + }, ): Promise { if (member.principalType !== "user") return "Only human company members can be removed."; if (req.actor.type !== "board") return "Board access is required to remove members."; if (member.principalId === req.actor.userId) return "You cannot remove yourself."; - if (await access.isInstanceAdmin(member.principalId)) { + const isTargetInstanceAdmin = opts?.instanceAdminUserIds + ? opts.instanceAdminUserIds.has(member.principalId) + : await access.isInstanceAdmin(member.principalId); + if (isTargetInstanceAdmin) { return "Instance admins cannot be removed from company access."; } @@ -1089,7 +1097,7 @@ async function getProtectedMemberReason( if (targetRole === "owner") return "Board owners cannot be removed from company access."; if (targetRole === "admin") return "Company admins cannot be removed from company access."; - const actorRole = await resolveActorHumanRole(req, access, companyId); + const actorRole = opts?.actorRole ?? await resolveActorHumanRole(req, access, companyId); if (!actorRole) return "Only active company members can remove users."; if (humanRoleRank[targetRole] >= humanRoleRank[actorRole]) { return "You can only remove users below your company role."; @@ -1110,13 +1118,30 @@ async function assertCanManageCompanyMember( async function addCompanyMemberRemovalAccess( req: Request, + db: Db, access: ReturnType, companyId: string, members: CompanyMemberRecord[], ) { + const actorRole = await resolveActorHumanRole(req, access, companyId); + const userIds = [...new Set(members + .filter((member) => member.principalType === "user") + .map((member) => member.principalId))]; + const instanceAdminUserIds = userIds.length > 0 + ? new Set( + await db + .select({ userId: instanceUserRoles.userId }) + .from(instanceUserRoles) + .where(and(inArray(instanceUserRoles.userId, userIds), eq(instanceUserRoles.role, "instance_admin"))) + .then((rows) => rows.map((row) => row.userId)), + ) + : new Set(); return Promise.all( members.map(async (member) => { - const reason = await getProtectedMemberReason(req, access, companyId, member); + const reason = await getProtectedMemberReason(req, access, companyId, member, { + actorRole, + instanceAdminUserIds, + }); return { ...member, removal: { @@ -3691,7 +3716,7 @@ export function accessRoutes( loadCompanyAccessSummary(req, access, companyId), ]); res.json({ - members: await addCompanyMemberRemovalAccess(req, access, companyId, members), + members: await addCompanyMemberRemovalAccess(req, db, access, companyId, members), access: currentAccess, }); }); diff --git a/server/src/routes/user-profiles.ts b/server/src/routes/user-profiles.ts index 35e624bdde9..7b50e94a36a 100644 --- a/server/src/routes/user-profiles.ts +++ b/server/src/routes/user-profiles.ts @@ -84,7 +84,8 @@ async function resolveCompanyUser(db: Db, companyId: string, rawSlug: string): P eq(companyMemberships.principalType, "user"), ), ) - .orderBy(desc(companyMemberships.updatedAt)); + .orderBy(desc(companyMemberships.updatedAt)) + .limit(200); return rows.find((row) => userSlugCandidates(row).includes(slug)) ?? null; }