diff --git a/messages/en-US.json b/messages/en-US.json index cdfd57110..361771d87 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -512,6 +512,8 @@ "autoProvisionedDescription": "Allow this user to be automatically managed by identity provider", "accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsSubmit": "Save Access Controls", + "singleRolePerUserPlanNotice": "Your plan only supports one role per user.", + "singleRolePerUserEditionNotice": "This edition only supports one role per user.", "roles": "Roles", "accessUsersRoles": "Manage Users & Roles", "accessUsersRolesDescription": "Invite users and add them to roles to manage access to the organization", diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index df8ea8cbb..f5749d529 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -26,6 +26,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 ssh from "#private/routers/ssh"; +import * as user from "#private/routers/user"; import { verifyOrgAccess, @@ -33,7 +34,10 @@ import { verifyUserIsServerAdmin, verifySiteAccess, verifyClientAccess, - verifyLimits + verifyLimits, + verifyRoleAccess, + verifyUserAccess, + verifyUserCanSetUserOrgRoles } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -518,3 +522,33 @@ authenticated.post( // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata ssh.signSshKey ); + +authenticated.post( + "/user/:userId/add-role/:roleId", + verifyRoleAccess, + verifyUserAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.addUserRole), + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole +); + +authenticated.delete( + "/user/:userId/remove-role/:roleId", + verifyRoleAccess, + verifyUserAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.removeUserRole), + logActionAudit(ActionsEnum.removeUserRole), + user.removeUserRole +); + +authenticated.post( + "/user/:userId/org/:orgId/roles", + verifyOrgAccess, + verifyUserAccess, + verifyLimits, + verifyUserCanSetUserOrgRoles(), + logActionAudit(ActionsEnum.setUserOrgRoles), + user.setUserOrgRoles +); diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 97b1adade..f8e6a63f4 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -20,8 +20,11 @@ import { verifyApiKeyIsRoot, verifyApiKeyOrgAccess, verifyApiKeyIdpAccess, + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, verifyLimits } from "@server/middlewares"; +import * as user from "#private/routers/user"; import { verifyValidSubscription, verifyValidLicense @@ -140,3 +143,23 @@ authenticated.get( verifyApiKeyHasAction(ActionsEnum.listIdps), orgIdp.listOrgIdps ); + +authenticated.post( + "/user/:userId/add-role/:roleId", + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.addUserRole), + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole +); + +authenticated.delete( + "/user/:userId/remove-role/:roleId", + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.removeUserRole), + logActionAudit(ActionsEnum.removeUserRole), + user.removeUserRole +); diff --git a/server/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts similarity index 91% rename from server/routers/user/addUserRole.ts rename to server/private/routers/user/addUserRole.ts index d41ad2051..a46bd1ed8 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -1,5 +1,19 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 stoi from "@server/lib/stoi"; import { clients, db } from "@server/db"; import { userOrgRoles, userOrgs, roles } from "@server/db"; import { eq, and } from "drizzle-orm"; @@ -8,7 +22,6 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; @@ -17,11 +30,9 @@ const addUserRoleParamsSchema = z.strictObject({ roleId: z.string().transform(stoi).pipe(z.number()) }); -export type AddUserRoleResponse = z.infer; - registry.registerPath({ method: "post", - path: "/role/{roleId}/add/{userId}", + path: "/user/{userId}/add-role/{roleId}", description: "Add a role to a user.", tags: [OpenAPITags.Role, OpenAPITags.User], request: { diff --git a/server/private/routers/user/index.ts b/server/private/routers/user/index.ts new file mode 100644 index 000000000..6317eced5 --- /dev/null +++ b/server/private/routers/user/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 "./addUserRole"; +export * from "./removeUserRole"; +export * from "./setUserOrgRoles"; diff --git a/server/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts similarity index 89% rename from server/routers/user/removeUserRole.ts rename to server/private/routers/user/removeUserRole.ts index 8d353fea3..e9c3d10c0 100644 --- a/server/routers/user/removeUserRole.ts +++ b/server/private/routers/user/removeUserRole.ts @@ -1,5 +1,19 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 stoi from "@server/lib/stoi"; import { db } from "@server/db"; import { userOrgRoles, userOrgs, roles, clients } from "@server/db"; import { eq, and } from "drizzle-orm"; @@ -8,7 +22,6 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; @@ -19,8 +32,9 @@ const removeUserRoleParamsSchema = z.strictObject({ registry.registerPath({ method: "delete", - path: "/role/{roleId}/remove/{userId}", - description: "Remove a role from a user. User must have at least one role left in the org.", + path: "/user/{userId}/remove-role/{roleId}", + description: + "Remove a role from a user. User must have at least one role left in the org.", tags: [OpenAPITags.Role, OpenAPITags.User], request: { params: removeUserRoleParamsSchema diff --git a/server/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts similarity index 92% rename from server/routers/user/setUserOrgRoles.ts rename to server/private/routers/user/setUserOrgRoles.ts index 525c91729..67563fd26 100644 --- a/server/routers/user/setUserOrgRoles.ts +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -1,3 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { clients, db } from "@server/db"; @@ -8,7 +21,6 @@ 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 } from "@server/lib/rebuildClientAssociations"; const setUserOrgRolesParamsSchema = z.strictObject({ diff --git a/server/routers/external.ts b/server/routers/external.ts index bb331dc69..03d5fa111 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -39,7 +39,6 @@ import { verifyApiKeyAccess, verifyDomainAccess, verifyUserHasAction, - verifyUserCanSetUserOrgRoles, verifyUserIsOrgOwner, verifySiteResourceAccess, verifyOlmAccess, @@ -645,6 +644,7 @@ authenticated.delete( logActionAudit(ActionsEnum.deleteRole), role.deleteRole ); + authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, @@ -652,17 +652,7 @@ authenticated.post( verifyLimits, verifyUserHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole -); - -authenticated.delete( - "/role/:roleId/remove/:userId", - verifyRoleAccess, - verifyUserAccess, - verifyLimits, - verifyUserHasAction(ActionsEnum.removeUserRole), - logActionAudit(ActionsEnum.removeUserRole), - user.removeUserRole + user.addUserRoleLegacy ); authenticated.post( @@ -838,16 +828,6 @@ authenticated.post( user.updateOrgUser ); -authenticated.post( - "/org/:orgId/user/:userId/roles", - verifyOrgAccess, - verifyUserAccess, - verifyLimits, - verifyUserCanSetUserOrgRoles(), - logActionAudit(ActionsEnum.setUserOrgRoles), - user.setUserOrgRoles -); - authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess); diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 32c06078b..2865b4bcb 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -596,17 +596,7 @@ authenticated.post( verifyLimits, verifyApiKeyHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole -); - -authenticated.delete( - "/role/:roleId/remove/:userId", - verifyApiKeyRoleAccess, - verifyApiKeyUserAccess, - verifyLimits, - verifyApiKeyHasAction(ActionsEnum.removeUserRole), - logActionAudit(ActionsEnum.removeUserRole), - user.removeUserRole + user.addUserRoleLegacy ); authenticated.post( @@ -815,16 +805,6 @@ authenticated.post( user.updateOrgUser ); -authenticated.post( - "/org/:orgId/user/:userId/roles", - verifyApiKeyOrgAccess, - verifyApiKeyUserAccess, - verifyLimits, - verifyApiKeyCanSetUserOrgRoles(), - logActionAudit(ActionsEnum.setUserOrgRoles), - user.setUserOrgRoles -); - authenticated.delete( "/org/:orgId/user/:userId", verifyApiKeyOrgAccess, diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts new file mode 100644 index 000000000..db0c6182f --- /dev/null +++ b/server/routers/user/addUserRoleLegacy.ts @@ -0,0 +1,159 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import stoi from "@server/lib/stoi"; +import { clients, db } from "@server/db"; +import { userOrgRoles, userOrgs, roles } from "@server/db"; +import { eq, and } 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 } from "@server/lib/rebuildClientAssociations"; + +/** Legacy path param order: /role/:roleId/add/:userId */ +const addUserRoleLegacyParamsSchema = z.strictObject({ + roleId: z.string().transform(stoi).pipe(z.number()), + userId: z.string() +}); + +registry.registerPath({ + method: "post", + path: "/role/{roleId}/add/{userId}", + description: + "Legacy: set exactly one role for the user (replaces any other roles the user has in the org).", + tags: [OpenAPITags.Role, OpenAPITags.User], + request: { + params: addUserRoleLegacyParamsSchema + }, + responses: {} +}); + +export async function addUserRoleLegacy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = addUserRoleLegacyParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, 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 [existingUser] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)) + ) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found or does not belong to the specified organization" + ) + ); + } + + if (existingUser.isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot change the role of the owner of the organization" + ) + ); + } + + const [roleInOrg] = await db + .select() + .from(roles) + .where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId))) + .limit(1); + + if (!roleInOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the specified organization" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId) + ) + ); + + await trx.insert(userOrgRoles).values({ + userId, + orgId: role.orgId, + roleId + }); + + const orgClients = await trx + .select() + .from(clients) + .where( + and( + eq(clients.userId, userId), + eq(clients.orgId, role.orgId) + ) + ); + + for (const orgClient of orgClients) { + await rebuildClientAssociationsFromClient(orgClient, trx); + } + }); + + return response(res, { + data: { ...existingUser, roleId }, + success: true, + error: false, + message: "Role added to user successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 0884e8a2b..e03676caa 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -1,9 +1,8 @@ export * from "./getUser"; export * from "./removeUserOrg"; export * from "./listUsers"; -export * from "./addUserRole"; -export * from "./removeUserRole"; -export * from "./setUserOrgRoles"; +export * from "./types"; +export * from "./addUserRoleLegacy"; export * from "./inviteUser"; export * from "./acceptInvite"; export * from "./getOrgUser"; diff --git a/server/routers/user/types.ts b/server/routers/user/types.ts new file mode 100644 index 000000000..bd5b54efa --- /dev/null +++ b/server/routers/user/types.ts @@ -0,0 +1,18 @@ +import type { UserOrg } from "@server/db"; + +export type AddUserRoleResponse = { + userId: string; + roleId: number; +}; + +/** Legacy POST /role/:roleId/add/:userId response shape (membership + effective role). */ +export type AddUserRoleLegacyResponse = UserOrg & { roleId: number }; + +export type SetUserOrgRolesParams = { + orgId: string; + userId: string; +}; + +export type SetUserOrgRolesBody = { + roleIds: number[]; +}; diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 6dcdf16fb..c9ed7d561 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -37,6 +37,9 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; import { UserType } from "@server/types/UserTypes"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { build } from "@server/build"; const accessControlsFormSchema = z.object({ username: z.string(), @@ -51,8 +54,9 @@ const accessControlsFormSchema = z.object({ export default function AccessControlsPage() { const { orgUser: user, updateOrgUser } = userOrgUserContext(); + const { env } = useEnvContext(); - const api = createApiClient(useEnvContext()); + const api = createApiClient({ env }); const { orgId } = useParams(); @@ -63,6 +67,18 @@ export default function AccessControlsPage() { ); const t = useTranslations(); + const { isPaidUser, hasSaasSubscription, hasEnterpriseLicense } = + usePaidStatus(); + const multiRoleFeatureTiers = Array.from( + new Set([...tierMatrix.sshPam, ...tierMatrix.orgOidc]) + ); + const isPaid = isPaidUser(multiRoleFeatureTiers); + const supportsMultipleRolesPerUser = isPaid; + const showMultiRolePaywallMessage = + !env.flags.disableEnterpriseFeatures && + ((build === "saas" && !isPaid) || + (build === "enterprise" && !isPaid) || + (build === "oss" && !isPaid)); const form = useForm({ resolver: zodResolver(accessControlsFormSchema), @@ -124,11 +140,28 @@ export default function AccessControlsPage() { [roles] ); - function setRoleTags( - updater: Tag[] | ((prev: Tag[]) => Tag[]) - ) { + function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) { const prev = form.getValues("roles"); - const next = typeof updater === "function" ? updater(prev) : updater; + const nextValue = + typeof updater === "function" ? updater(prev) : updater; + const next = supportsMultipleRolesPerUser + ? nextValue + : nextValue.length > 1 + ? [nextValue[nextValue.length - 1]] + : nextValue; + + // In single-role mode, selecting the currently selected role can transiently + // emit an empty tag list from TagInput; keep the prior selection. + if ( + !supportsMultipleRolesPerUser && + next.length === 0 && + prev.length > 0 + ) { + form.setValue("roles", [prev[prev.length - 1]], { + shouldDirty: true + }); + return; + } if (next.length === 0) { toast({ @@ -155,11 +188,14 @@ export default function AccessControlsPage() { setLoading(true); try { const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + const updateRoleRequest = supportsMultipleRolesPerUser + ? api.post(`/user/${user.userId}/org/${orgId}/roles`, { + roleIds + }) + : api.post(`/role/${roleIds[0]}/add/${user.userId}`); await Promise.all([ - api.post(`/org/${orgId}/user/${user.userId}/roles`, { - roleIds - }), + updateRoleRequest, api.post(`/org/${orgId}/user/${user.userId}`, { autoProvisioned: values.autoProvisioned }) @@ -233,7 +269,7 @@ export default function AccessControlsPage() { name="roles" render={({ field }) => ( - {t("role")} + {t("roles")} + {showMultiRolePaywallMessage && ( + + {build === "saas" + ? t( + "singleRolePerUserPlanNotice" + ) + : t( + "singleRolePerUserEditionNotice" + )} + + )} )} diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 01be1aedb..80a4b9926 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -96,7 +96,7 @@ function getActionsCategories(root: boolean) { [t("actionUpdateRole")]: "updateRole", [t("actionListAllowedRoleResources")]: "listRoleResources", [t("actionAddUserRole")]: "addUserRole", - [t("actionSetUserOrgRoles")]: "setUserOrgRoles" + [t("actionRemoveUserRole")]: "removeUserRole" }, "Access Token": { [t("actionGenerateAccessToken")]: "generateAccessToken",