diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 0b96141e9..10b83f38d 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Изтрийте потребител", "actionListUsers": "Изброяване на потребители", "actionAddUserRole": "Добавяне на роля на потребител", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Генериране на токен за достъп", "actionDeleteAccessToken": "Изтриване на токен за достъп", "actionListAccessTokens": "Изброяване на токени за достъп", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index cb5372b36..ea12fe535 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Odstranit uživatele", "actionListUsers": "Seznam uživatelů", "actionAddUserRole": "Přidat uživatelskou roli", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Generovat přístupový token", "actionDeleteAccessToken": "Odstranit přístupový token", "actionListAccessTokens": "Seznam přístupových tokenů", diff --git a/messages/de-DE.json b/messages/de-DE.json index 150a8597e..cef7223fb 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Benutzer entfernen", "actionListUsers": "Benutzer auflisten", "actionAddUserRole": "Benutzerrolle hinzufügen", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Zugriffstoken generieren", "actionDeleteAccessToken": "Zugriffstoken löschen", "actionListAccessTokens": "Zugriffstoken auflisten", diff --git a/messages/en-US.json b/messages/en-US.json index a9c9c5583..4d1bb02be 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -514,9 +514,12 @@ "userSaved": "User saved", "userSavedDescription": "The user has been updated.", "autoProvisioned": "Auto Provisioned", + "autoProvisionSettings": "Auto Provision Settings", "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", @@ -892,7 +895,7 @@ "defaultMappingsRole": "Default Role Mapping", "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Default Organization Mapping", - "defaultMappingsOrgDescription": "This expression must return the org ID or true for the user to be allowed to access the organization.", + "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining an organization policy for that org is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", "defaultMappingsSubmit": "Save Default Mappings", "orgPoliciesEdit": "Edit Organization Policy", "org": "Organization", @@ -1045,7 +1048,6 @@ "pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.", "overview": "Overview", "home": "Home", - "accessControl": "Access Control", "settings": "Settings", "usersAll": "All Users", "license": "License", @@ -1155,6 +1157,7 @@ "actionRemoveUser": "Remove User", "actionListUsers": "List Users", "actionAddUserRole": "Add User Role", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Generate Access Token", "actionDeleteAccessToken": "Delete Access Token", "actionListAccessTokens": "List Access Tokens", @@ -1944,6 +1947,25 @@ "invalidValue": "Invalid value", "idpTypeLabel": "Identity Provider Type", "roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'", + "roleMappingModeFixedRoles": "Fixed Roles", + "roleMappingModeMappingBuilder": "Mapping Builder", + "roleMappingModeRawExpression": "Raw Expression", + "roleMappingFixedRolesPlaceholderSelect": "Select one or more roles", + "roleMappingFixedRolesPlaceholderFreeform": "Type role names (exact match per organization)", + "roleMappingFixedRolesDescriptionSameForAll": "Assign the same role set to every auto-provisioned user.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "For default policies, type role names that exist in each organization where users are provisioned. Names must match exactly.", + "roleMappingClaimPath": "Claim Path", + "roleMappingClaimPathPlaceholder": "groups", + "roleMappingClaimPathDescription": "Path in the token payload that contains source values (for example, groups).", + "roleMappingMatchValue": "Match Value", + "roleMappingAssignRoles": "Assign Roles", + "roleMappingAddMappingRule": "Add Mapping Rule", + "roleMappingRawExpressionResultDescription": "Expression must evaluate to a string or string array.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Expression must evaluate to a string (a single role name).", + "roleMappingMatchValuePlaceholder": "Match value (for example: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)", + "roleMappingBuilderFreeformRowHint": "Role names must match a role in each target organization.", + "roleMappingRemoveRule": "Remove", "idpGoogleConfiguration": "Google Configuration", "idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2516,9 +2538,9 @@ "remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?", "remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.", "agent": "Agent", - "personalUseOnly": "Personal Use Only", - "loginPageLicenseWatermark": "This instance is licensed for personal use only.", - "instanceIsUnlicensed": "This instance is unlicensed.", + "personalUseOnly": "Personal Use Only", + "loginPageLicenseWatermark": "This instance is licensed for personal use only.", + "instanceIsUnlicensed": "This instance is unlicensed.", "portRestrictions": "Port Restrictions", "allPorts": "All", "custom": "Custom", @@ -2572,7 +2594,7 @@ "automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.", "forced": "Forced", "forcedModeDescription": "Always show the maintenance page regardless of backend health. Use this for planned maintenance when you want to prevent all access.", - "warning:" : "Warning:", + "warning:": "Warning:", "forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.", "pageTitle": "Page Title", "pageTitleDescription": "The main heading displayed on the maintenance page", @@ -2689,5 +2711,6 @@ "approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.", "approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review", "approvalsEmptyStateButtonText": "Manage Roles", - "domainErrorTitle": "We are having trouble verifying your domain" + "domainErrorTitle": "We are having trouble verifying your domain", + "idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the Auto Provision Settings tab." } diff --git a/messages/es-ES.json b/messages/es-ES.json index e33a85ace..2fc52b885 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Eliminar usuario", "actionListUsers": "Listar usuarios", "actionAddUserRole": "Añadir rol de usuario", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Generar token de acceso", "actionDeleteAccessToken": "Eliminar token de acceso", "actionListAccessTokens": "Lista de Tokens de Acceso", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index ec3dbffb8..2b3368a07 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Supprimer un utilisateur", "actionListUsers": "Lister les utilisateurs", "actionAddUserRole": "Ajouter un rôle utilisateur", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Générer un jeton d'accès", "actionDeleteAccessToken": "Supprimer un jeton d'accès", "actionListAccessTokens": "Lister les jetons d'accès", diff --git a/messages/it-IT.json b/messages/it-IT.json index adab7879a..6891cd290 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Rimuovi Utente", "actionListUsers": "Elenca Utenti", "actionAddUserRole": "Aggiungi Ruolo Utente", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Genera Token di Accesso", "actionDeleteAccessToken": "Elimina Token di Accesso", "actionListAccessTokens": "Elenca Token di Accesso", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 59f464305..02915abd7 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "사용자 제거", "actionListUsers": "사용자 목록", "actionAddUserRole": "사용자 역할 추가", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "액세스 토큰 생성", "actionDeleteAccessToken": "액세스 토큰 삭제", "actionListAccessTokens": "액세스 토큰 목록", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index e8a9fa9a3..50ec9a717 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Fjern bruker", "actionListUsers": "List opp brukere", "actionAddUserRole": "Legg til brukerrolle", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Generer tilgangstoken", "actionDeleteAccessToken": "Slett tilgangstoken", "actionListAccessTokens": "List opp tilgangstokener", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 32580cc45..f0eaff3fd 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Gebruiker verwijderen", "actionListUsers": "Gebruikers weergeven", "actionAddUserRole": "Gebruikersrol toevoegen", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Genereer Toegangstoken", "actionDeleteAccessToken": "Verwijder toegangstoken", "actionListAccessTokens": "Lijst toegangstokens", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index ba0587b94..998fcc880 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Usuń użytkownika", "actionListUsers": "Lista użytkowników", "actionAddUserRole": "Dodaj rolę użytkownika", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Wygeneruj token dostępu", "actionDeleteAccessToken": "Usuń token dostępu", "actionListAccessTokens": "Lista tokenów dostępu", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 3ce98fff6..b121f4b16 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Remover Utilizador", "actionListUsers": "Listar Utilizadores", "actionAddUserRole": "Adicionar Função ao Utilizador", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Gerar Token de Acesso", "actionDeleteAccessToken": "Eliminar Token de Acesso", "actionListAccessTokens": "Listar Tokens de Acesso", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 12043d8a2..0d42245e4 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Удалить пользователя", "actionListUsers": "Список пользователей", "actionAddUserRole": "Добавить роль пользователя", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Сгенерировать токен доступа", "actionDeleteAccessToken": "Удалить токен доступа", "actionListAccessTokens": "Список токенов доступа", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 362f891fb..2bfed7fb3 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "Kullanıcıyı Kaldır", "actionListUsers": "Kullanıcıları Listele", "actionAddUserRole": "Kullanıcı Rolü Ekle", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Erişim Jetonu Oluştur", "actionDeleteAccessToken": "Erişim Jetonunu Sil", "actionListAccessTokens": "Erişim Jetonlarını Listele", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index a7f2682fa..f297d1ea9 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1148,6 +1148,7 @@ "actionRemoveUser": "删除用户", "actionListUsers": "列出用户", "actionAddUserRole": "添加用户角色", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "生成访问令牌", "actionDeleteAccessToken": "删除访问令牌", "actionListAccessTokens": "访问令牌", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 1ae6a5156..8b9d05f53 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1091,6 +1091,7 @@ "actionRemoveUser": "刪除用戶", "actionListUsers": "列出用戶", "actionAddUserRole": "添加用戶角色", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "生成訪問令牌", "actionDeleteAccessToken": "刪除訪問令牌", "actionListAccessTokens": "訪問令牌", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 450e3f42b..ae5136659 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -1,9 +1,10 @@ import { Request } from "express"; import { db } from "@server/db"; -import { userActions, roleActions, userOrgs } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { userActions, roleActions } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export enum ActionsEnum { createOrgUser = "createOrgUser", @@ -53,6 +54,8 @@ export enum ActionsEnum { listRoleResources = "listRoleResources", // listRoleActions = "listRoleActions", addUserRole = "addUserRole", + removeUserRole = "removeUserRole", + setUserOrgRoles = "setUserOrgRoles", // addUserSite = "addUserSite", // addUserAction = "addUserAction", // removeUserAction = "removeUserAction", @@ -154,29 +157,16 @@ export async function checkUserActionPermission( } try { - let userOrgRoleId = req.userOrgRoleId; + let userOrgRoleIds = req.userOrgRoleIds; - // If userOrgRoleId is not available on the request, fetch it - if (userOrgRoleId === undefined) { - const userOrgRole = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, req.userOrgId!) - ) - ) - .limit(1); - - if (userOrgRole.length === 0) { + if (userOrgRoleIds === undefined) { + userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!); + if (userOrgRoleIds.length === 0) { throw createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ); } - - userOrgRoleId = userOrgRole[0].roleId; } // Check if the user has direct permission for the action in the current org @@ -187,7 +177,7 @@ export async function checkUserActionPermission( and( eq(userActions.userId, userId), eq(userActions.actionId, actionId), - eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org + eq(userActions.orgId, req.userOrgId!) ) ) .limit(1); @@ -196,14 +186,14 @@ export async function checkUserActionPermission( return true; } - // If no direct permission, check role-based permission + // If no direct permission, check role-based permission (any of user's roles) const roleActionPermission = await db .select() .from(roleActions) .where( and( eq(roleActions.actionId, actionId), - eq(roleActions.roleId, userOrgRoleId!), + inArray(roleActions.roleId, userOrgRoleIds), eq(roleActions.orgId, req.userOrgId!) ) ) diff --git a/server/auth/canUserAccessResource.ts b/server/auth/canUserAccessResource.ts index 161a0bee9..2c8911490 100644 --- a/server/auth/canUserAccessResource.ts +++ b/server/auth/canUserAccessResource.ts @@ -1,26 +1,29 @@ import { db } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { roleResources, userResources } from "@server/db"; export async function canUserAccessResource({ userId, resourceId, - roleId + roleIds }: { userId: string; resourceId: number; - roleId: number; + roleIds: number[]; }): Promise { - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) - ) - ) - .limit(1); + const roleResourceAccess = + roleIds.length > 0 + ? await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return true; diff --git a/server/auth/canUserAccessSiteResource.ts b/server/auth/canUserAccessSiteResource.ts index 959b0eff6..7e6ec9bb8 100644 --- a/server/auth/canUserAccessSiteResource.ts +++ b/server/auth/canUserAccessSiteResource.ts @@ -1,26 +1,29 @@ import { db } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { roleSiteResources, userSiteResources } from "@server/db"; export async function canUserAccessSiteResource({ userId, resourceId, - roleId + roleIds }: { userId: string; resourceId: number; - roleId: number; + roleIds: number[]; }): Promise { - const roleResourceAccess = await db - .select() - .from(roleSiteResources) - .where( - and( - eq(roleSiteResources.siteResourceId, resourceId), - eq(roleSiteResources.roleId, roleId) - ) - ) - .limit(1); + const roleResourceAccess = + roleIds.length > 0 + ? await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, resourceId), + inArray(roleSiteResources.roleId, roleIds) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return true; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b93c21fd6..2bd9624e7 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -6,9 +6,11 @@ import { index, integer, pgTable, + primaryKey, real, serial, text, + unique, varchar } from "drizzle-orm/pg-core"; @@ -335,9 +337,6 @@ export const userOrgs = pgTable("userOrgs", { onDelete: "cascade" }) .notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId), isOwner: boolean("isOwner").notNull().default(false), autoProvisioned: boolean("autoProvisioned").default(false), pamUsername: varchar("pamUsername") // cleaned username for ssh and such @@ -386,6 +385,22 @@ export const roles = pgTable("roles", { sshUnixGroups: text("sshUnixGroups").default("[]") }); +export const userOrgRoles = pgTable( + "userOrgRoles", + { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [unique().on(t.userId, t.orgId, t.roleId)] +); + export const roleActions = pgTable("roleActions", { roleId: integer("roleId") .notNull() @@ -453,12 +468,22 @@ export const userInvites = pgTable("userInvites", { .references(() => orgs.orgId, { onDelete: "cascade" }), email: varchar("email").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), - tokenHash: varchar("token").notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }) + tokenHash: varchar("token").notNull() }); +export const userInviteRoles = pgTable( + "userInviteRoles", + { + inviteId: varchar("inviteId") + .notNull() + .references(() => userInvites.inviteId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.inviteId, t.roleId] })] +); + export const resourcePincode = pgTable("resourcePincode", { pincodeId: serial("pincodeId").primaryKey(), resourceId: integer("resourceId") @@ -1034,7 +1059,9 @@ export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type UserInvite = InferSelectModel; +export type UserInviteRole = InferSelectModel; export type UserOrg = InferSelectModel; +export type UserOrgRole = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 280c8a119..989e111a7 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -1,4 +1,12 @@ -import { db, loginPage, LoginPage, loginPageOrg, Org, orgs, roles } from "@server/db"; +import { + db, + loginPage, + LoginPage, + loginPageOrg, + Org, + orgs, + roles +} from "@server/db"; import { Resource, ResourcePassword, @@ -12,13 +20,12 @@ import { resources, roleResources, sessions, - userOrgs, userResources, users, ResourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; export type ResourceWithAuth = { resource: Resource | null; @@ -104,24 +111,15 @@ export async function getUserSessionWithUser( } /** - * Get user organization role + * Get role name by role ID (for display). */ -export async function getUserOrgRole(userId: string, orgId: string) { - const userOrgRole = await db - .select({ - userId: userOrgs.userId, - orgId: userOrgs.orgId, - roleId: userOrgs.roleId, - isOwner: userOrgs.isOwner, - autoProvisioned: userOrgs.autoProvisioned, - roleName: roles.name - }) - .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) +export async function getRoleName(roleId: number): Promise { + const [row] = await db + .select({ name: roles.name }) + .from(roles) + .where(eq(roles.roleId, roleId)) .limit(1); - - return userOrgRole.length > 0 ? userOrgRole[0] : null; + return row?.name ?? null; } /** @@ -129,7 +127,7 @@ export async function getUserOrgRole(userId: string, orgId: string) { */ export async function getRoleResourceAccess( resourceId: number, - roleId: number + roleIds: number[] ) { const roleResourceAccess = await db .select() @@ -137,12 +135,11 @@ export async function getRoleResourceAccess( .where( and( eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) + inArray(roleResources.roleId, roleIds) ) - ) - .limit(1); + ); - return roleResourceAccess.length > 0 ? roleResourceAccess[0] : null; + return roleResourceAccess.length > 0 ? roleResourceAccess : null; } /** diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 188caac2b..b43f3b4a6 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,6 +1,13 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + primaryKey, + sqliteTable, + text, + unique +} from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -643,9 +650,6 @@ export const userOrgs = sqliteTable("userOrgs", { onDelete: "cascade" }) .notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId), isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), autoProvisioned: integer("autoProvisioned", { mode: "boolean" @@ -700,6 +704,22 @@ export const roles = sqliteTable("roles", { sshUnixGroups: text("sshUnixGroups").default("[]") }); +export const userOrgRoles = sqliteTable( + "userOrgRoles", + { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [unique().on(t.userId, t.orgId, t.roleId)] +); + export const roleActions = sqliteTable("roleActions", { roleId: integer("roleId") .notNull() @@ -785,12 +805,22 @@ export const userInvites = sqliteTable("userInvites", { .references(() => orgs.orgId, { onDelete: "cascade" }), email: text("email").notNull(), expiresAt: integer("expiresAt").notNull(), - tokenHash: text("token").notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }) + tokenHash: text("token").notNull() }); +export const userInviteRoles = sqliteTable( + "userInviteRoles", + { + inviteId: text("inviteId") + .notNull() + .references(() => userInvites.inviteId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.inviteId, t.roleId] })] +); + export const resourcePincode = sqliteTable("resourcePincode", { pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true @@ -1133,7 +1163,9 @@ export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type UserInvite = InferSelectModel; +export type UserInviteRole = InferSelectModel; export type UserOrg = InferSelectModel; +export type UserOrgRole = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index a61daca7f..0fc44c279 100644 --- a/server/index.ts +++ b/server/index.ts @@ -74,7 +74,7 @@ declare global { session: Session; userOrg?: UserOrg; apiKeyOrg?: ApiKeyOrg; - userOrgRoleId?: number; + userOrgRoleIds?: number[]; userOrgId?: string; userOrgIds?: string[]; remoteExitNode?: RemoteExitNode; diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index c08bcea71..a66f566a9 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -15,7 +15,8 @@ export enum TierFeature { SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning - SshPam = "sshPam" + SshPam = "sshPam", + FullRbac = "fullRbac" } export const tierMatrix: Record = { @@ -48,5 +49,6 @@ export const tierMatrix: Record = { "enterprise" ], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], - [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] + [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], + [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 4be76dddc..02ac0c417 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -10,6 +10,7 @@ import { roles, Transaction, userClients, + userOrgRoles, userOrgs } from "@server/db"; import { getUniqueClientName } from "@server/db/names"; @@ -39,20 +40,36 @@ export async function calculateUserClientsForOrgs( return; } - // Get all user orgs - const allUserOrgs = await transaction + // Get all user orgs with all roles (for org list and role-based logic) + const userOrgRoleRows = await transaction .select() .from(userOrgs) - .innerJoin(roles, eq(roles.roleId, userOrgs.roleId)) + .innerJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where(eq(userOrgs.userId, userId)); - const userOrgIds = allUserOrgs.map(({ userOrgs: uo }) => uo.orgId); + const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))]; + const orgIdToRoleRows = new Map< + string, + (typeof userOrgRoleRows)[0][] + >(); + for (const r of userOrgRoleRows) { + const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? []; + list.push(r); + orgIdToRoleRows.set(r.userOrgs.orgId, list); + } // For each OLM, ensure there's a client in each org the user is in for (const olm of userOlms) { - for (const userRoleOrg of allUserOrgs) { - const { userOrgs: userOrg, roles: role } = userRoleOrg; - const orgId = userOrg.orgId; + for (const orgId of orgIdToRoleRows.keys()) { + const roleRowsForOrg = orgIdToRoleRows.get(orgId)!; + const userOrg = roleRowsForOrg[0].userOrgs; const [org] = await transaction .select() @@ -196,7 +213,7 @@ export async function calculateUserClientsForOrgs( const requireApproval = build !== "oss" && isOrgLicensed && - role.requireDeviceApproval; + roleRowsForOrg.some((r) => r.roles.requireDeviceApproval); const newClientData: InferInsertModel = { userId, diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index f6c8592e9..c3e796fc1 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -79,6 +79,7 @@ export const configSchema = z .default(3001) .transform(stoi) .pipe(portSchema), + badger_override: z.string().optional(), next_port: portSchema .optional() .default(3002) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 7ef8d5c2a..8459ce249 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -14,6 +14,7 @@ import { siteResources, sites, Transaction, + userOrgRoles, userOrgs, userSiteResources } from "@server/db"; @@ -77,10 +78,10 @@ export async function getClientSiteResourceAccess( // get all of the users in these roles const userIdsFromRoles = await trx .select({ - userId: userOrgs.userId + userId: userOrgRoles.userId }) - .from(userOrgs) - .where(inArray(userOrgs.roleId, roleIds)) + .from(userOrgRoles) + .where(inArray(userOrgRoles.roleId, roleIds)) .then((rows) => rows.map((row) => row.userId)); const newAllUserIds = Array.from( @@ -814,12 +815,12 @@ export async function rebuildClientAssociationsFromClient( // Role-based access const roleIds = await trx - .select({ roleId: userOrgs.roleId }) - .from(userOrgs) + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) .where( and( - eq(userOrgs.userId, client.userId), - eq(userOrgs.orgId, client.orgId) + eq(userOrgRoles.userId, client.userId), + eq(userOrgRoles.orgId, client.orgId) ) ) // this needs to be locked onto this org or else cross-org access could happen .then((rows) => rows.map((row) => row.roleId)); diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts index 6ed10039b..809266b73 100644 --- a/server/lib/userOrg.ts +++ b/server/lib/userOrg.ts @@ -6,7 +6,7 @@ import { siteResources, sites, Transaction, - UserOrg, + userOrgRoles, userOrgs, userResources, userSiteResources, @@ -19,9 +19,22 @@ import { FeatureId } from "@server/lib/billing"; export async function assignUserToOrg( org: Org, values: typeof userOrgs.$inferInsert, + roleIds: number[], trx: Transaction | typeof db = db ) { + const uniqueRoleIds = [...new Set(roleIds)]; + if (uniqueRoleIds.length === 0) { + throw new Error("assignUserToOrg requires at least one roleId"); + } + const [userOrg] = await trx.insert(userOrgs).values(values).returning(); + await trx.insert(userOrgRoles).values( + uniqueRoleIds.map((roleId) => ({ + userId: userOrg.userId, + orgId: userOrg.orgId, + roleId + })) + ); // calculate if the user is in any other of the orgs before we count it as an add to the billing org if (org.billingOrgId) { @@ -58,6 +71,14 @@ export async function removeUserFromOrg( userId: string, trx: Transaction | typeof db = db ) { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, org.orgId) + ) + ); await trx .delete(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId))); diff --git a/server/lib/userOrgRoles.ts b/server/lib/userOrgRoles.ts new file mode 100644 index 000000000..c3db64af3 --- /dev/null +++ b/server/lib/userOrgRoles.ts @@ -0,0 +1,36 @@ +import { db, roles, userOrgRoles } from "@server/db"; +import { and, eq } from "drizzle-orm"; + +/** + * Get all role IDs a user has in an organization. + * Returns empty array if the user has no roles in the org (callers must treat as no access). + */ +export async function getUserOrgRoleIds( + userId: string, + orgId: string +): Promise { + const rows = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + return rows.map((r) => r.roleId); +} + +export async function getUserOrgRoles( + userId: string, + orgId: string +): Promise<{ roleId: number; roleName: string }[]> { + const rows = await db + .select({ roleId: userOrgRoles.roleId, roleName: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) + ); + return rows; +} diff --git a/server/middlewares/getUserOrgs.ts b/server/middlewares/getUserOrgs.ts index d7905700e..fa9794fb9 100644 --- a/server/middlewares/getUserOrgs.ts +++ b/server/middlewares/getUserOrgs.ts @@ -21,8 +21,7 @@ export async function getUserOrgs( try { const userOrganizations = await db .select({ - orgId: userOrgs.orgId, - roleId: userOrgs.roleId + orgId: userOrgs.orgId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 6437c90e2..435ccdb23 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -17,6 +17,7 @@ export * from "./verifyAccessTokenAccess"; export * from "./requestTimeout"; export * from "./verifyClientAccess"; export * from "./verifyUserHasAction"; +export * from "./verifyUserCanSetUserOrgRoles"; export * from "./verifyUserIsServerAdmin"; export * from "./verifyIsLoggedInUser"; export * from "./verifyIsLoggedInUser"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index df186c1c8..8a213c6d2 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -1,6 +1,7 @@ export * from "./verifyApiKey"; export * from "./verifyApiKeyOrgAccess"; export * from "./verifyApiKeyHasAction"; +export * from "./verifyApiKeyCanSetUserOrgRoles"; export * from "./verifyApiKeySiteAccess"; export * from "./verifyApiKeyResourceAccess"; export * from "./verifyApiKeyTargetAccess"; diff --git a/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts b/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts new file mode 100644 index 000000000..894665095 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts @@ -0,0 +1,74 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { ActionsEnum } from "@server/auth/actions"; +import { db } from "@server/db"; +import { apiKeyActions } from "@server/db"; +import { and, eq } from "drizzle-orm"; + +async function apiKeyHasAction(apiKeyId: string, actionId: ActionsEnum) { + const [row] = await db + .select() + .from(apiKeyActions) + .where( + and( + eq(apiKeyActions.apiKeyId, apiKeyId), + eq(apiKeyActions.actionId, actionId) + ) + ); + return !!row; +} + +/** + * Allows setUserOrgRoles on the key, or both addUserRole and removeUserRole. + */ +export function verifyApiKeyCanSetUserOrgRoles() { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + if (!req.apiKey) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "API Key not authenticated" + ) + ); + } + + const keyId = req.apiKey.apiKeyId; + + if (await apiKeyHasAction(keyId, ActionsEnum.setUserOrgRoles)) { + return next(); + } + + const hasAdd = await apiKeyHasAction(keyId, ActionsEnum.addUserRole); + const hasRemove = await apiKeyHasAction( + keyId, + ActionsEnum.removeUserRole + ); + + if (hasAdd && hasRemove) { + return next(); + } + + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have permission perform this action" + ) + ); + } catch (error) { + logger.error("Error verifying API key set user org roles:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key action access" + ) + ); + } + }; +} diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 033b326d9..f1f2ca52e 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "@server/auth/canUserAccessResource"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyAccessTokenAccess( req: Request, @@ -93,7 +94,10 @@ export async function verifyAccessTokenAccess( ) ); } else { - req.userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource[0].orgId! + ); req.userOrgId = resource[0].orgId!; } @@ -118,7 +122,7 @@ export async function verifyAccessTokenAccess( const resourceAllowed = await canUserAccessResource({ userId, resourceId, - roleId: req.userOrgRoleId! + roleIds: req.userOrgRoleIds ?? [] }); if (!resourceAllowed) { diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 253bfc2dd..0dbeac2cb 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { roles, userOrgs } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyAdmin( req: Request, @@ -62,13 +63,29 @@ export async function verifyAdmin( } } - const userRole = await db + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId!); + + if (req.userOrgRoleIds.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have Admin access" + ) + ); + } + + const userAdminRoles = await db .select() .from(roles) - .where(eq(roles.roleId, req.userOrg.roleId)) + .where( + and( + inArray(roles.roleId, req.userOrgRoleIds), + eq(roles.isAdmin, true) + ) + ) .limit(1); - if (userRole.length === 0 || !userRole[0].isAdmin) { + if (userAdminRoles.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts index 6edc5ab8e..b497892c8 100644 --- a/server/middlewares/verifyApiKeyAccess.ts +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs, apiKeys, apiKeyOrg } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyApiKeyAccess( req: Request, @@ -103,8 +104,10 @@ export async function verifyApiKeyAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + orgId + ); return next(); } catch (error) { diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts index d2df38a4b..1d994b53f 100644 --- a/server/middlewares/verifyClientAccess.ts +++ b/server/middlewares/verifyClientAccess.ts @@ -1,11 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { Client, db } from "@server/db"; import { userOrgs, clients, roleClients, userClients } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import logger from "@server/logger"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyClientAccess( req: Request, @@ -113,21 +114,30 @@ export async function verifyClientAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + client.orgId + ); req.userOrgId = client.orgId; - // Check role-based site access first - const [roleClientAccess] = await db - .select() - .from(roleClients) - .where( - and( - eq(roleClients.clientId, client.clientId), - eq(roleClients.roleId, userOrgRoleId) - ) - ) - .limit(1); + // Check role-based client access (any of user's roles) + const roleClientAccessList = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleClients) + .where( + and( + eq(roleClients.clientId, client.clientId), + inArray( + roleClients.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; + const [roleClientAccess] = roleClientAccessList; if (roleClientAccess) { // User has access to the site through their role diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts index 88ffe678d..c9ecf42e0 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db, domains, orgDomains } from "@server/db"; -import { userOrgs, apiKeyOrg } from "@server/db"; +import { 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"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyDomainAccess( req: Request, @@ -63,7 +64,7 @@ export async function verifyDomainAccess( .where( and( eq(userOrgs.userId, userId), - eq(userOrgs.orgId, apiKeyOrg.orgId) + eq(userOrgs.orgId, orgId) ) ) .limit(1); @@ -97,8 +98,7 @@ export async function verifyDomainAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); return next(); } catch (error) { diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 729766abd..cb797afb0 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; -import { db, orgs } from "@server/db"; +import { db } from "@server/db"; import { 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"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyOrgAccess( req: Request, @@ -64,8 +65,8 @@ export async function verifyOrgAccess( } } - // User has access, attach the user's role to the request for potential future use - req.userOrgRoleId = req.userOrg.roleId; + // User has access, attach the user's role(s) to the request for potential future use + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); req.userOrgId = orgId; return next(); diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index 2ae591ee1..ba49f02e3 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db, Resource } from "@server/db"; import { resources, userOrgs, userResources, roleResources } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyResourceAccess( req: Request, @@ -107,20 +108,28 @@ export async function verifyResourceAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource.orgId + ); req.userOrgId = resource.orgId; - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resource.resourceId), - eq(roleResources.roleId, userOrgRoleId) - ) - ) - .limit(1); + const roleResourceAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resource.resourceId), + inArray( + roleResources.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return next(); diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index 8858ab53f..380b82048 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyRoleAccess( req: Request, @@ -99,7 +100,6 @@ export async function verifyRoleAccess( } if (!req.userOrg) { - // get the userORg const userOrg = await db .select() .from(userOrgs) @@ -109,7 +109,7 @@ export async function verifyRoleAccess( .limit(1); req.userOrg = userOrg[0]; - req.userOrgRoleId = userOrg[0].roleId; + req.userOrgRoleIds = await getUserOrgRoleIds(userId, orgId!); } if (!req.userOrg) { diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index 98858cfb9..e630cf0f1 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { sites, Site, userOrgs, userSites, roleSites, roles } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq, inArray, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifySiteAccess( req: Request, @@ -112,21 +113,29 @@ export async function verifySiteAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + site.orgId + ); req.userOrgId = site.orgId; - // Check role-based site access first - const roleSiteAccess = await db - .select() - .from(roleSites) - .where( - and( - eq(roleSites.siteId, site.siteId), - eq(roleSites.roleId, userOrgRoleId) - ) - ) - .limit(1); + // Check role-based site access first (any of user's roles) + const roleSiteAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleSites) + .where( + and( + eq(roleSites.siteId, site.siteId), + inArray( + roleSites.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleSiteAccess.length > 0) { // User's role has access to the site diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index ca7d37fb3..8d5bd656f 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -1,11 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db"; import { siteResources } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifySiteResourceAccess( req: Request, @@ -109,23 +110,34 @@ export async function verifySiteResourceAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + siteResource.orgId + ); req.userOrgId = siteResource.orgId; // Attach the siteResource to the request for use in the next middleware/route req.siteResource = siteResource; - const roleResourceAccess = await db - .select() - .from(roleSiteResources) - .where( - and( - eq(roleSiteResources.siteResourceId, siteResourceIdNum), - eq(roleSiteResources.roleId, userOrgRoleId) - ) - ) - .limit(1); + const roleResourceAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleSiteResources) + .where( + and( + eq( + roleSiteResources.siteResourceId, + siteResourceIdNum + ), + inArray( + roleSiteResources.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return next(); diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 7e433fcb8..141a04549 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "../auth/canUserAccessResource"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyTargetAccess( req: Request, @@ -99,7 +100,10 @@ export async function verifyTargetAccess( ) ); } else { - req.userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource[0].orgId! + ); req.userOrgId = resource[0].orgId!; } @@ -126,7 +130,7 @@ export async function verifyTargetAccess( const resourceAllowed = await canUserAccessResource({ userId, resourceId, - roleId: req.userOrgRoleId! + roleIds: req.userOrgRoleIds ?? [] }); if (!resourceAllowed) { diff --git a/server/middlewares/verifyUserCanSetUserOrgRoles.ts b/server/middlewares/verifyUserCanSetUserOrgRoles.ts new file mode 100644 index 000000000..1a7554ab3 --- /dev/null +++ b/server/middlewares/verifyUserCanSetUserOrgRoles.ts @@ -0,0 +1,54 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; + +/** + * Allows the new setUserOrgRoles action, or legacy permission pair addUserRole + removeUserRole. + */ +export function verifyUserCanSetUserOrgRoles() { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const canSet = await checkUserActionPermission( + ActionsEnum.setUserOrgRoles, + req + ); + if (canSet) { + return next(); + } + + const canAdd = await checkUserActionPermission( + ActionsEnum.addUserRole, + req + ); + const canRemove = await checkUserActionPermission( + ActionsEnum.removeUserRole, + req + ); + + if (canAdd && canRemove) { + return next(); + } + + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } catch (error) { + logger.error("Error verifying set user org roles access:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying role access" + ) + ); + } + }; +} diff --git a/server/middlewares/verifyUserInRole.ts b/server/middlewares/verifyUserInRole.ts index 2a153114d..18eeb44f3 100644 --- a/server/middlewares/verifyUserInRole.ts +++ b/server/middlewares/verifyUserInRole.ts @@ -12,7 +12,7 @@ export async function verifyUserInRole( const roleId = parseInt( req.params.roleId || req.body.roleId || req.query.roleId ); - const userRoleId = req.userOrgRoleId; + const userOrgRoleIds = req.userOrgRoleIds ?? []; if (isNaN(roleId)) { return next( @@ -20,7 +20,7 @@ export async function verifyUserInRole( ); } - if (!userRoleId) { + if (userOrgRoleIds.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -29,7 +29,7 @@ export async function verifyUserInRole( ); } - if (userRoleId !== roleId) { + if (!userOrgRoleIds.includes(roleId)) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/private/middlewares/verifyIdpAccess.ts b/server/private/middlewares/verifyIdpAccess.ts index 410956844..2dbc1b8ff 100644 --- a/server/private/middlewares/verifyIdpAccess.ts +++ b/server/private/middlewares/verifyIdpAccess.ts @@ -13,9 +13,10 @@ import { Request, Response, NextFunction } from "express"; import { userOrgs, db, idp, idpOrg } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyIdpAccess( req: Request, @@ -84,8 +85,10 @@ export async function verifyIdpAccess( ); } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + idpRes.idpOrg.orgId + ); return next(); } catch (error) { diff --git a/server/private/middlewares/verifyRemoteExitNodeAccess.ts b/server/private/middlewares/verifyRemoteExitNodeAccess.ts index a2cd2bace..7d6128d8f 100644 --- a/server/private/middlewares/verifyRemoteExitNodeAccess.ts +++ b/server/private/middlewares/verifyRemoteExitNodeAccess.ts @@ -12,11 +12,12 @@ */ import { Request, Response, NextFunction } from "express"; -import { db, exitNodeOrgs, exitNodes, remoteExitNodes } from "@server/db"; -import { sites, userOrgs, userSites, roleSites, roles } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { db, exitNodeOrgs, remoteExitNodes } from "@server/db"; +import { userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyRemoteExitNodeAccess( req: Request, @@ -103,8 +104,10 @@ export async function verifyRemoteExitNodeAccess( ); } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + exitNodeOrg.orgId + ); return next(); } catch (error) { diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 9536a87f0..330cf6e03 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -26,9 +26,10 @@ import { orgs, resources, roles, - siteResources + siteResources, + userOrgRoles } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; /** * Get the maximum allowed retention days for a given tier @@ -291,6 +292,10 @@ async function disableFeature( await disableSshPam(orgId); break; + case TierFeature.FullRbac: + await disableFullRbac(orgId); + break; + default: logger.warn( `Unknown feature ${feature} for org ${orgId}, skipping` @@ -326,6 +331,10 @@ async function disableSshPam(orgId: string): Promise { ); } +async function disableFullRbac(orgId: string): Promise { + logger.info(`Disabled full RBAC for org ${orgId}`); +} + async function disableLoginPageBranding(orgId: string): Promise { const [existingBranding] = await db .select() 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/hybrid.ts b/server/private/routers/hybrid.ts index a38385b0c..13a6f70e0 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -52,7 +52,9 @@ import { userOrgs, roleResources, userResources, - resourceRules + resourceRules, + userOrgRoles, + roles } from "@server/db"; import { eq, and, inArray, isNotNull, ne } from "drizzle-orm"; import { response } from "@server/lib/response"; @@ -104,6 +106,13 @@ const getUserOrgSessionVerifySchema = z.strictObject({ sessionId: z.string().min(1, "Session ID is required") }); +const getRoleNameParamsSchema = z.strictObject({ + roleId: z + .string() + .transform(Number) + .pipe(z.int().positive("Role ID must be a positive integer")) +}); + const getRoleResourceAccessParamsSchema = z.strictObject({ roleId: z .string() @@ -115,6 +124,23 @@ const getRoleResourceAccessParamsSchema = z.strictObject({ .pipe(z.int().positive("Resource ID must be a positive integer")) }); +const getResourceAccessParamsSchema = z.strictObject({ + resourceId: z + .string() + .transform(Number) + .pipe(z.int().positive("Resource ID must be a positive integer")) +}); + +const getResourceAccessQuerySchema = z.strictObject({ + roleIds: z + .union([z.array(z.string()), z.string()]) + .transform((val) => + (Array.isArray(val) ? val : [val]) + .map(Number) + .filter((n) => !isNaN(n)) + ) +}); + const getUserResourceAccessParamsSchema = z.strictObject({ userId: z.string().min(1, "User ID is required"), resourceId: z @@ -760,7 +786,7 @@ hybridRouter.get( // Get user organization role hybridRouter.get( - "/user/:userId/org/:orgId/role", + "/user/:userId/org/:orgId/roles", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getUserOrgRoleParamsSchema.safeParse( @@ -796,23 +822,129 @@ hybridRouter.get( ); } - const userOrgRole = await db - .select() - .from(userOrgs) + const userOrgRoleRows = await db + .select({ roleId: userOrgRoles.roleId, roleName: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(roles.roleId, userOrgRoles.roleId)) .where( - and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) - ) - .limit(1); + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); - const result = userOrgRole.length > 0 ? userOrgRole[0] : null; + logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows); - return response(res, { - data: result, + return response<{ roleId: number, roleName: string }[]>(res, { + data: userOrgRoleRows, success: true, error: false, - message: result - ? "User org role retrieved successfully" - : "User org role not found", + message: + userOrgRoleRows.length > 0 + ? "User org roles retrieved successfully" + : "User has no roles in this organization", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get user org role" + ) + ); + } + } +); + +// DEPRICATED Get user organization role +// used for backward compatibility with old remote nodes +hybridRouter.get( + "/user/:userId/org/:orgId/role", // <- note the missing s + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getUserOrgRoleParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, orgId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "User is not authorized to access this organization" + ) + ); + } + + // get the roles on the user + + const userOrgRoleRows = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const roleIds = userOrgRoleRows.map((r) => r.roleId); + + let roleId: number | null = null; + + if (userOrgRoleRows.length === 0) { + // User has no roles in this organization + roleId = null; + } else if (userOrgRoleRows.length === 1) { + // User has exactly one role, return it + roleId = userOrgRoleRows[0].roleId; + } else { + // User has multiple roles + // Check if any of these roles are also assigned to a resource + // If we find a match, prefer that role; otherwise return the first role + // Get all resources that have any of these roles assigned + const roleResourceMatches = await db + .select({ roleId: roleResources.roleId }) + .from(roleResources) + .where(inArray(roleResources.roleId, roleIds)) + .limit(1); + if (roleResourceMatches.length > 0) { + // Return the first role that's also on a resource + roleId = roleResourceMatches[0].roleId; + } else { + // No resource match found, return the first role + roleId = userOrgRoleRows[0].roleId; + } + } + + return response<{ roleId: number | null }>(res, { + data: { roleId }, + success: true, + error: false, + message: + roleIds.length > 0 + ? "User org roles retrieved successfully" + : "User has no roles in this organization", status: HttpCode.OK }); } catch (error) { @@ -890,6 +1022,60 @@ hybridRouter.get( } ); +// Get role name by ID +hybridRouter.get( + "/role/:roleId/name", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getRoleNameParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { roleId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [role] = await db + .select({ name: roles.name }) + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + return response(res, { + data: role?.name ?? null, + success: true, + error: false, + message: role + ? "Role name retrieved successfully" + : "Role not found", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get role name" + ) + ); + } + } +); + // Check if role has access to resource hybridRouter.get( "/role/:roleId/resource/:resourceId/access", @@ -975,6 +1161,101 @@ hybridRouter.get( } ); +// Check if role has access to resource +hybridRouter.get( + "/resource/:resourceId/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getResourceAccessParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const parsedQuery = getResourceAccessQuerySchema.safeParse( + req.query + ); + const roleIds = parsedQuery.success ? parsedQuery.data.roleIds : []; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + resource.orgId + ) + ) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const roleResourceAccess = await db + .select({ + resourceId: roleResources.resourceId, + roleId: roleResources.roleId + }) + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ); + + const result = + roleResourceAccess.length > 0 ? roleResourceAccess : null; + + return response<{ resourceId: number; roleId: number }[] | null>( + res, + { + data: result, + success: true, + error: false, + message: result + ? "Role resource access retrieved successfully" + : "Role resource access not found", + status: HttpCode.OK + } + ); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get role resource access" + ) + ); + } + } +); + // Check if user has direct access to resource hybridRouter.get( "/user/:userId/resource/:resourceId/access", @@ -1873,7 +2154,8 @@ hybridRouter.post( // userAgent: data.userAgent, // TODO: add this // headers: data.body.headers, // query: data.body.query, - originalRequestURL: sanitizeString(logEntry.originalRequestURL) ?? "", + originalRequestURL: + sanitizeString(logEntry.originalRequestURL) ?? "", scheme: sanitizeString(logEntry.scheme) ?? "", host: sanitizeString(logEntry.host) ?? "", path: sanitizeString(logEntry.path) ?? "", 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/private/routers/org/sendUsageNotifications.ts b/server/private/routers/org/sendUsageNotifications.ts index 4aa421520..72fc00d4c 100644 --- a/server/private/routers/org/sendUsageNotifications.ts +++ b/server/private/routers/org/sendUsageNotifications.ts @@ -14,7 +14,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userOrgs, users, roles, orgs } from "@server/db"; +import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db"; import { eq, and, or } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -95,7 +95,14 @@ async function getOrgAdmins(orgId: string) { }) .from(userOrgs) .innerJoin(users, eq(userOrgs.userId, users.userId)) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) + .leftJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where( and( eq(userOrgs.orgId, orgId), @@ -103,8 +110,11 @@ async function getOrgAdmins(orgId: string) { ) ); - // Filter to only include users with verified emails - const orgAdmins = admins.filter( + // Dedupe by userId (user may have multiple roles) + const byUserId = new Map( + admins.map((a) => [a.userId, a]) + ); + const orgAdmins = Array.from(byUserId.values()).filter( (admin) => admin.email && admin.email.length > 0 ); diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index 6d5b5ea6f..f24afdde1 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -79,7 +79,7 @@ export async function createRemoteExitNode( const { remoteExitNodeId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index c39d2e0ae..e8de55c54 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -32,7 +32,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, or, and } from "drizzle-orm"; +import { and, eq, inArray, or } from "drizzle-orm"; import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA"; import config from "@server/lib/config"; @@ -126,7 +126,7 @@ export async function signSshKey( resource: resourceQueryString } = parsedBody.data; const userId = req.user?.userId; - const roleId = req.userOrgRoleId!; + const roleIds = req.userOrgRoleIds ?? []; if (!userId) { return next( @@ -134,6 +134,15 @@ export async function signSshKey( ); } + if (roleIds.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User has no role in organization" + ) + ); + } + const [userOrg] = await db .select() .from(userOrgs) @@ -340,7 +349,7 @@ export async function signSshKey( const hasAccess = await canUserAccessSiteResource({ userId: userId, resourceId: resource.siteResourceId, - roleId: roleId + roleIds }); if (!hasAccess) { @@ -352,28 +361,39 @@ export async function signSshKey( ); } - const [roleRow] = await db + const roleRows = await db .select() .from(roles) - .where(eq(roles.roleId, roleId)) - .limit(1); + .where(inArray(roles.roleId, roleIds)); - let parsedSudoCommands: string[] = []; - let parsedGroups: string[] = []; - try { - parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); - if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = []; - } catch { - parsedSudoCommands = []; + const parsedSudoCommands: string[] = []; + const parsedGroupsSet = new Set(); + let homedir: boolean | null = null; + const sudoModeOrder = { none: 0, commands: 1, all: 2 }; + let sudoMode: "none" | "commands" | "all" = "none"; + for (const roleRow of roleRows) { + try { + const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); + if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds); + } catch { + // skip + } + try { + const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); + if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g)); + } catch { + // skip + } + if (roleRow?.sshCreateHomeDir === true) homedir = true; + const m = roleRow?.sshSudoMode ?? "none"; + if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) { + sudoMode = m as "none" | "commands" | "all"; + } } - try { - parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); - if (!Array.isArray(parsedGroups)) parsedGroups = []; - } catch { - parsedGroups = []; + const parsedGroups = Array.from(parsedGroupsSet); + if (homedir === null && roleRows.length > 0) { + homedir = roleRows[0].sshCreateHomeDir ?? null; } - const homedir = roleRow?.sshCreateHomeDir ?? null; - const sudoMode = roleRow?.sshSudoMode ?? "none"; // get the site const [newt] = await db diff --git a/server/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts similarity index 78% rename from server/routers/user/addUserRole.ts rename to server/private/routers/user/addUserRole.ts index 32eaa19d7..a46bd1ed8 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -1,14 +1,27 @@ +/* + * 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, UserOrg } from "@server/db"; -import { userOrgs, roles } from "@server/db"; +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 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: { @@ -111,20 +122,23 @@ export async function addUserRole( ); } - let newUserRole: UserOrg | null = null; + let newUserRole: { userId: string; orgId: string; roleId: number } | null = + null; await db.transaction(async (trx) => { - [newUserRole] = await trx - .update(userOrgs) - .set({ roleId }) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, role.orgId) - ) - ) + const inserted = await trx + .insert(userOrgRoles) + .values({ + userId, + orgId: role.orgId, + roleId + }) + .onConflictDoNothing() .returning(); - // get the client associated with this user in this org + if (inserted.length > 0) { + newUserRole = inserted[0]; + } + const orgClients = await trx .select() .from(clients) @@ -133,17 +147,15 @@ export async function addUserRole( eq(clients.userId, userId), eq(clients.orgId, role.orgId) ) - ) - .limit(1); + ); for (const orgClient of orgClients) { - // we just changed the user's role, so we need to rebuild client associations and what they have access to await rebuildClientAssociationsFromClient(orgClient, trx); } }); return response(res, { - data: newUserRole, + data: newUserRole ?? { userId, orgId: role.orgId, roleId }, success: true, error: false, message: "Role added to user successfully", 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/private/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts new file mode 100644 index 000000000..e9c3d10c0 --- /dev/null +++ b/server/private/routers/user/removeUserRole.ts @@ -0,0 +1,171 @@ +/* + * 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"; +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"; + +const removeUserRoleParamsSchema = z.strictObject({ + userId: z.string(), + roleId: z.string().transform(stoi).pipe(z.number()) +}); + +registry.registerPath({ + method: "delete", + 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 + }, + responses: {} +}); + +export async function removeUserRole( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = removeUserRoleParamsSchema.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 roles of the owner of the organization" + ) + ); + } + + const remainingRoles = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId) + ) + ); + + if (remainingRoles.length <= 1) { + const hasThisRole = remainingRoles.some((r) => r.roleId === roleId); + if (hasThisRole) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User must have at least one role in the organization. Remove the last role is not allowed." + ) + ); + } + } + + await db.transaction(async (trx) => { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId), + eq(userOrgRoles.roleId, 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: { userId, orgId: role.orgId, roleId }, + success: true, + error: false, + message: "Role removed from user 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/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts new file mode 100644 index 000000000..67563fd26 --- /dev/null +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -0,0 +1,163 @@ +/* + * 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"; +import { userOrgRoles, userOrgs, roles } from "@server/db"; +import { eq, and, 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 { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; + +const setUserOrgRolesParamsSchema = z.strictObject({ + orgId: z.string(), + userId: z.string() +}); + +const setUserOrgRolesBodySchema = z.strictObject({ + roleIds: z.array(z.int().positive()).min(1) +}); + +export async function setUserOrgRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setUserOrgRolesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = setUserOrgRolesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, userId } = 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 [existingUser] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in this organization" + ) + ); + } + + if (existingUser.isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot change the roles of the owner of the organization" + ) + ); + } + + 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" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + if (uniqueRoleIds.length > 0) { + await trx.insert(userOrgRoles).values( + uniqueRoleIds.map((roleId) => ({ + userId, + orgId, + roleId + })) + ); + } + + const orgClients = await trx + .select() + .from(clients) + .where( + and(eq(clients.userId, userId), eq(clients.orgId, orgId)) + ); + + for (const orgClient of orgClients) { + await rebuildClientAssociationsFromClient(orgClient, trx); + } + }); + + return response(res, { + data: { userId, orgId, roleIds: uniqueRoleIds }, + success: true, + error: false, + message: "User 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/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 9f8747190..55751df81 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -208,7 +208,7 @@ export async function listAccessTokens( .where( or( eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + inArray(roleResources.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index e99052cd7..0b1cc1183 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -4,11 +4,11 @@ import { getResourceByDomain, getResourceRules, getRoleResourceAccess, - getUserOrgRole, getUserResourceAccess, getOrgLoginPage, getUserSessionWithUser } from "@server/db/queries/verifySessionQueries"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import { LoginPage, Org, @@ -30,7 +30,6 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; import { getAsnForIp } from "@server/lib/asn"; -import { getOrgTierData } from "#dynamic/lib/billing"; import { verifyPassword } from "@server/auth/password"; import { checkOrgAccessPolicy, @@ -797,7 +796,8 @@ async function notAllowed( ) { let loginPage: LoginPage | null = null; if (orgId) { - const subscribed = await isSubscribed( // this is fine because the org login page is only a saas feature + const subscribed = await isSubscribed( + // this is fine because the org login page is only a saas feature orgId, tierMatrix.loginPageDomain ); @@ -854,7 +854,10 @@ async function headerAuthChallenged( ) { let loginPage: LoginPage | null = null; if (orgId) { - const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain); // this is fine because the org login page is only a saas feature + const subscribed = await isSubscribed( + orgId, + tierMatrix.loginPageDomain + ); // this is fine because the org login page is only a saas feature if (subscribed) { loginPage = await getOrgLoginPage(orgId); } @@ -916,9 +919,9 @@ async function isUserAllowedToAccessResource( return null; } - const userOrgRole = await getUserOrgRole(user.userId, resource.orgId); + const userOrgRoles = await getUserOrgRoles(user.userId, resource.orgId); - if (!userOrgRole) { + if (!userOrgRoles.length) { return null; } @@ -936,15 +939,14 @@ async function isUserAllowedToAccessResource( const roleResourceAccess = await getRoleResourceAccess( resource.resourceId, - userOrgRole.roleId + userOrgRoles.map((r) => r.roleId) ); - - if (roleResourceAccess) { + if (roleResourceAccess && roleResourceAccess.length > 0) { return { username: user.username, email: user.email, name: user.name, - role: userOrgRole.roleName + role: userOrgRoles.map((r) => r.roleName).join(", ") }; } @@ -958,7 +960,7 @@ async function isUserAllowedToAccessResource( username: user.username, email: user.email, name: user.name, - role: userOrgRole.roleName + role: userOrgRoles.map((r) => r.roleName).join(", ") }; } diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index 6f26d8cf3..337d7e714 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -92,7 +92,7 @@ export async function createClient( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -234,7 +234,7 @@ export async function createClient( clientId: newClient.clientId }); - if (req.user && req.userOrgRoleId != adminRole.roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole.roleId)) { // make sure the user can access the client trx.insert(userClients).values({ userId: req.user.userId, diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 3b7adf2d5..0bf798509 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -297,7 +297,7 @@ export async function listClients( .where( or( eq(userClients.userId, req.user!.userId), - eq(roleClients.roleId, req.userOrgRoleId!) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index e99760b91..0ae31165a 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -316,7 +316,7 @@ export async function listUserDevices( .where( or( eq(userClients.userId, req.user!.userId), - eq(roleClients.roleId, req.userOrgRoleId!) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/external.ts b/server/routers/external.ts index 45ab58bba..03d5fa111 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -644,6 +644,7 @@ authenticated.delete( logActionAudit(ActionsEnum.deleteRole), role.deleteRole ); + authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, @@ -651,7 +652,7 @@ authenticated.post( verifyLimits, verifyUserHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole + user.addUserRoleLegacy ); authenticated.post( diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 5b53f6820..0f0cc0cce 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -25,7 +25,8 @@ const bodySchema = z.strictObject({ namePath: z.string().optional(), scopes: z.string().nonempty(), autoProvision: z.boolean().optional(), - tags: z.string().optional() + tags: z.string().optional(), + variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc") }); export type CreateIdpResponse = { @@ -77,7 +78,8 @@ export async function createOidcIdp( namePath, name, autoProvision, - tags + tags, + variant } = parsedBody.data; if ( @@ -121,7 +123,8 @@ export async function createOidcIdp( scopes, identifierPath, emailPath, - namePath + namePath, + variant }); }); diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index fe32a8b08..905b32013 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -31,7 +31,8 @@ const bodySchema = z.strictObject({ autoProvision: z.boolean().optional(), defaultRoleMapping: z.string().optional(), defaultOrgMapping: z.string().optional(), - tags: z.string().optional() + tags: z.string().optional(), + variant: z.enum(["oidc", "google", "azure"]).optional() }); export type UpdateIdpResponse = { @@ -96,7 +97,8 @@ export async function updateOidcIdp( autoProvision, defaultRoleMapping, defaultOrgMapping, - tags + tags, + variant } = parsedBody.data; if (process.env.IDENTITY_PROVIDER_MODE === "org") { @@ -159,7 +161,8 @@ export async function updateOidcIdp( scopes, identifierPath, emailPath, - namePath + namePath, + variant }; keysToUpdate = Object.keys(configData).filter( diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index e34621856..7c9e53cf2 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -13,6 +13,7 @@ import { orgs, Role, roles, + userOrgRoles, userOrgs, users } from "@server/db"; @@ -35,11 +36,13 @@ import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg"; +import { unwrapRoleMapping } from "@app/lib/idpRoleMapping"; const ensureTrailingSlash = (url: string): string => { return url; @@ -366,7 +369,7 @@ export async function validateOidcCallback( const defaultRoleMapping = existingIdp.idp.defaultRoleMapping; const defaultOrgMapping = existingIdp.idp.defaultOrgMapping; - const userOrgInfo: { orgId: string; roleId: number }[] = []; + const userOrgInfo: { orgId: string; roleIds: number[] }[] = []; for (const org of allOrgs) { const [idpOrgRes] = await db .select() @@ -378,8 +381,6 @@ export async function validateOidcCallback( ) ); - let roleId: number | undefined = undefined; - const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; const hydratedOrgMapping = hydrateOrgMapping( orgMapping, @@ -404,38 +405,55 @@ export async function validateOidcCallback( idpOrgRes?.roleMapping || defaultRoleMapping; if (roleMapping) { logger.debug("Role Mapping", { roleMapping }); - const roleName = jmespath.search(claims, roleMapping); + const roleMappingJmes = unwrapRoleMapping( + roleMapping + ).evaluationExpression; + const roleMappingResult = jmespath.search( + claims, + roleMappingJmes + ); + const roleNames = normalizeRoleMappingResult( + roleMappingResult + ); - if (!roleName) { - logger.error("Role name not found in the ID token", { - roleName + const supportsMultiRole = await isLicensedOrSubscribed( + org.orgId, + tierMatrix.fullRbac + ); + const effectiveRoleNames = supportsMultiRole + ? roleNames + : roleNames.slice(0, 1); + + if (!effectiveRoleNames.length) { + logger.error("Role mapping returned no valid roles", { + roleMappingResult }); continue; } - const [roleRes] = await db + const roleRes = await db .select() .from(roles) .where( and( eq(roles.orgId, org.orgId), - eq(roles.name, roleName) + inArray(roles.name, effectiveRoleNames) ) ); - if (!roleRes) { - logger.error("Role not found", { + if (!roleRes.length) { + logger.error("No mapped roles found in organization", { orgId: org.orgId, - roleName + roleNames: effectiveRoleNames }); continue; } - roleId = roleRes.roleId; + const roleIds = [...new Set(roleRes.map((r) => r.roleId))]; userOrgInfo.push({ orgId: org.orgId, - roleId + roleIds }); } } @@ -570,32 +588,28 @@ export async function validateOidcCallback( } } - // Update roles for existing auto-provisioned orgs where the role has changed - const orgsToUpdate = autoProvisionedOrgs.filter( - (currentOrg) => { - const newOrg = userOrgInfo.find( - (newOrg) => newOrg.orgId === currentOrg.orgId - ); - return newOrg && newOrg.roleId !== currentOrg.roleId; - } - ); + // Sync roles 1:1 with IdP policy for existing auto-provisioned orgs + for (const currentOrg of autoProvisionedOrgs) { + const newRole = userOrgInfo.find( + (newOrg) => newOrg.orgId === currentOrg.orgId + ); + if (!newRole) continue; - if (orgsToUpdate.length > 0) { - for (const org of orgsToUpdate) { - const newRole = userOrgInfo.find( - (newOrg) => newOrg.orgId === org.orgId + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId!), + eq(userOrgRoles.orgId, currentOrg.orgId) + ) ); - if (newRole) { - await trx - .update(userOrgs) - .set({ roleId: newRole.roleId }) - .where( - and( - eq(userOrgs.userId, userId!), - eq(userOrgs.orgId, org.orgId) - ) - ); - } + + for (const roleId of newRole.roleIds) { + await trx.insert(userOrgRoles).values({ + userId: userId!, + orgId: currentOrg.orgId, + roleId + }); } } @@ -609,6 +623,10 @@ export async function validateOidcCallback( if (orgsToAdd.length > 0) { for (const org of orgsToAdd) { + if (org.roleIds.length === 0) { + continue; + } + const [fullOrg] = await trx .select() .from(orgs) @@ -619,9 +637,9 @@ export async function validateOidcCallback( { orgId: org.orgId, userId: userId!, - roleId: org.roleId, autoProvisioned: true, }, + org.roleIds, trx ); } @@ -748,3 +766,25 @@ function hydrateOrgMapping( } return orgMapping.split("{{orgId}}").join(orgId); } + +function normalizeRoleMappingResult( + result: unknown +): string[] { + if (typeof result === "string") { + const role = result.trim(); + return role ? [role] : []; + } + + if (Array.isArray(result)) { + return [ + ...new Set( + result + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + ) + ]; + } + + return []; +} diff --git a/server/routers/integration.ts b/server/routers/integration.ts index d2b31b524..2865b4bcb 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -16,6 +16,7 @@ import { verifyApiKey, verifyApiKeyOrgAccess, verifyApiKeyHasAction, + verifyApiKeyCanSetUserOrgRoles, verifyApiKeySiteAccess, verifyApiKeyResourceAccess, verifyApiKeyTargetAccess, @@ -595,7 +596,7 @@ authenticated.post( verifyLimits, verifyApiKeyHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole + user.addUserRoleLegacy ); authenticated.post( diff --git a/server/routers/newt/createNewt.ts b/server/routers/newt/createNewt.ts index b5da405e6..68cdcacf8 100644 --- a/server/routers/newt/createNewt.ts +++ b/server/routers/newt/createNewt.ts @@ -46,7 +46,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/olm/createOlm.ts b/server/routers/olm/createOlm.ts index b5da405e6..68cdcacf8 100644 --- a/server/routers/olm/createOlm.ts +++ b/server/routers/olm/createOlm.ts @@ -46,7 +46,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/org/checkOrgUserAccess.ts b/server/routers/org/checkOrgUserAccess.ts index d9f0364e3..19e39c4fe 100644 --- a/server/routers/org/checkOrgUserAccess.ts +++ b/server/routers/org/checkOrgUserAccess.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idp, idpOidcConfig } from "@server/db"; -import { roles, userOrgs, users } from "@server/db"; +import { roles, userOrgRoles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -14,7 +14,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; async function queryUser(orgId: string, userId: string) { - const [user] = await db + const [userRow] = await db .select({ orgId: userOrgs.orgId, userId: users.userId, @@ -22,10 +22,7 @@ async function queryUser(orgId: string, userId: string) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, - isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, autoProvisioned: userOrgs.autoProvisioned, idpId: users.idpId, @@ -35,13 +32,40 @@ async function queryUser(orgId: string, userId: string) { idpAutoProvision: idp.autoProvision }) .from(userOrgs) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - return user; + + if (!userRow) return undefined; + + const roleRows = await db + .select({ + roleId: userOrgRoles.roleId, + roleName: roles.name, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const isAdmin = roleRows.some((r) => r.isAdmin); + + return { + ...userRow, + isAdmin, + roleIds: roleRows.map((r) => r.roleId), + roles: roleRows.map((r) => ({ + roleId: r.roleId, + name: r.roleName ?? "" + })) + }; } export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult; diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index ec9581e75..88f76c29c 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -9,6 +9,7 @@ import { orgs, roleActions, roles, + userOrgRoles, userOrgs, users, actions @@ -312,9 +313,13 @@ export async function createOrg( await trx.insert(userOrgs).values({ userId: req.user!.userId, orgId: newOrg[0].orgId, - roleId: roleId, isOwner: true }); + await trx.insert(userOrgRoles).values({ + userId: req.user!.userId, + orgId: newOrg[0].orgId, + roleId + }); ownerUserId = req.user!.userId; } else { // if org created by root api key, set the server admin as the owner @@ -332,9 +337,13 @@ export async function createOrg( await trx.insert(userOrgs).values({ userId: serverAdmin.userId, orgId: newOrg[0].orgId, - roleId: roleId, isOwner: true }); + await trx.insert(userOrgRoles).values({ + userId: serverAdmin.userId, + orgId: newOrg[0].orgId, + roleId + }); ownerUserId = serverAdmin.userId; } diff --git a/server/routers/org/getOrgOverview.ts b/server/routers/org/getOrgOverview.ts index d368d1b3c..fcdd7c0ed 100644 --- a/server/routers/org/getOrgOverview.ts +++ b/server/routers/org/getOrgOverview.ts @@ -117,20 +117,26 @@ export async function getOrgOverview( .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); - const [role] = await db - .select() - .from(roles) - .where(eq(roles.roleId, req.userOrg.roleId)); + const roleIds = req.userOrgRoleIds ?? []; + const roleRows = + roleIds.length > 0 + ? await db + .select({ name: roles.name, isAdmin: roles.isAdmin }) + .from(roles) + .where(inArray(roles.roleId, roleIds)) + : []; + const userRoleName = roleRows.map((r) => r.name ?? "").join(", ") ?? ""; + const isAdmin = roleRows.some((r) => r.isAdmin === true); return response(res, { data: { orgName: org[0].name, orgId: org[0].orgId, - userRoleName: role.name, + userRoleName, numSites, numUsers, numResources, - isAdmin: role.isAdmin || false, + isAdmin, isOwner: req.userOrg?.isOwner || false }, success: true, diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index 301d0203e..8e6ce649d 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, roles } from "@server/db"; -import { Org, orgs, userOrgs } from "@server/db"; +import { Org, orgs, userOrgRoles, userOrgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -82,10 +82,7 @@ export async function listUserOrgs( const { userId } = parsedParams.data; const userOrganizations = await db - .select({ - orgId: userOrgs.orgId, - roleId: userOrgs.roleId - }) + .select({ orgId: userOrgs.orgId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); @@ -116,10 +113,27 @@ export async function listUserOrgs( userOrgs, and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId)) ) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .limit(limit) .offset(offset); + const roleRows = await db + .select({ + orgId: userOrgRoles.orgId, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + inArray(userOrgRoles.orgId, userOrgIds) + ) + ); + + const orgHasAdmin = new Set( + roleRows.filter((r) => r.isAdmin).map((r) => r.orgId) + ); + const totalCountResult = await db .select({ count: sql`cast(count(*) as integer)` }) .from(orgs) @@ -133,8 +147,8 @@ export async function listUserOrgs( if (val.userOrgs && val.userOrgs.isOwner) { res.isOwner = val.userOrgs.isOwner; } - if (val.roles && val.roles.isAdmin) { - res.isAdmin = val.roles.isAdmin; + if (val.orgs && orgHasAdmin.has(val.orgs.orgId)) { + res.isAdmin = true; } if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) { res.isPrimaryOrg = val.orgs.isBillingOrg; diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index e07880ac2..6cff4d23a 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -112,7 +112,7 @@ export async function createResource( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -292,7 +292,7 @@ async function createHttpResource( resourceId: newResource[0].resourceId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, @@ -385,7 +385,7 @@ async function createRawResource( resourceId: newResource[0].resourceId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index eb5f8a8d9..9afd6b4f3 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -5,6 +5,7 @@ import { resources, userResources, roleResources, + userOrgRoles, userOrgs, resourcePassword, resourcePincode, @@ -32,22 +33,29 @@ export async function getUserResources( ); } - // First get the user's role in the organization - const userOrgResult = await db - .select({ - roleId: userOrgs.roleId - }) + // Check user is in organization and get their role IDs + const [userOrg] = await db + .select() .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - if (userOrgResult.length === 0) { + if (!userOrg) { return next( createHttpError(HttpCode.FORBIDDEN, "User not in organization") ); } - const userRoleId = userOrgResult[0].roleId; + const userRoleIds = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ) + .then((rows) => rows.map((r) => r.roleId)); // Get resources accessible through direct assignment or role assignment const directResourcesQuery = db @@ -55,20 +63,28 @@ export async function getUserResources( .from(userResources) .where(eq(userResources.userId, userId)); - const roleResourcesQuery = db - .select({ resourceId: roleResources.resourceId }) - .from(roleResources) - .where(eq(roleResources.roleId, userRoleId)); + const roleResourcesQuery = + userRoleIds.length > 0 + ? db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .where(inArray(roleResources.roleId, userRoleIds)) + : Promise.resolve([]); const directSiteResourcesQuery = db .select({ siteResourceId: userSiteResources.siteResourceId }) .from(userSiteResources) .where(eq(userSiteResources.userId, userId)); - const roleSiteResourcesQuery = db - .select({ siteResourceId: roleSiteResources.siteResourceId }) - .from(roleSiteResources) - .where(eq(roleSiteResources.roleId, userRoleId)); + const roleSiteResourcesQuery = + userRoleIds.length > 0 + ? db + .select({ + siteResourceId: roleSiteResources.siteResourceId + }) + .from(roleSiteResources) + .where(inArray(roleSiteResources.roleId, userRoleIds)) + : Promise.resolve([]); const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ directResourcesQuery, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index f9dd14e98..fa7ec8a48 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -305,7 +305,7 @@ export async function listResources( .where( or( eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + inArray(roleResources.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts index 490fe91cc..4d2797250 100644 --- a/server/routers/role/deleteRole.ts +++ b/server/routers/role/deleteRole.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roles, userOrgs } from "@server/db"; -import { eq } from "drizzle-orm"; +import { roles, userOrgRoles } from "@server/db"; +import { and, eq, exists, aliasedTable } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -114,13 +114,32 @@ export async function deleteRole( } await db.transaction(async (trx) => { - // move all users from the userOrgs table with roleId to newRoleId - await trx - .update(userOrgs) - .set({ roleId: newRoleId }) - .where(eq(userOrgs.roleId, roleId)); + const uorNewRole = aliasedTable(userOrgRoles, "user_org_roles_new"); + + // Users who already have newRoleId: drop the old assignment only (unique on userId+orgId+roleId). + await trx.delete(userOrgRoles).where( + and( + eq(userOrgRoles.roleId, roleId), + exists( + trx + .select() + .from(uorNewRole) + .where( + and( + eq(uorNewRole.userId, userOrgRoles.userId), + eq(uorNewRole.orgId, userOrgRoles.orgId), + eq(uorNewRole.roleId, newRoleId) + ) + ) + ) + ) + ); + + await trx + .update(userOrgRoles) + .set({ roleId: newRoleId }) + .where(eq(userOrgRoles.roleId, roleId)); - // delete the old role await trx.delete(roles).where(eq(roles.roleId, roleId)); }); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index cc63a2e9b..4edebb080 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -111,7 +111,7 @@ export async function createSite( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -399,7 +399,7 @@ export async function createSite( siteId: newSite.siteId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the site trx.insert(userSites).values({ userId: req.user?.userId!, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index a87ad3daf..a244c650c 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -235,7 +235,7 @@ export async function listSites( .where( or( eq(userSites.userId, req.user!.userId), - eq(roleSites.roleId, req.userOrgRoleId!) + inArray(roleSites.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index fa76190ff..02f890604 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -30,12 +30,15 @@ export async function traefikConfigProvider( traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { [badgerMiddlewareName]: { - apiBaseUrl: new URL( - "/api/v1", - `http://${ - config.getRawConfig().server.internal_hostname - }:${config.getRawConfig().server.internal_port}` - ).href, + apiBaseUrl: + config.getRawConfig().server.badger_override || + new URL( + "/api/v1", + `http://${ + config.getRawConfig().server + .internal_hostname + }:${config.getRawConfig().server.internal_port}` + ).href, userSessionCookieName: config.getRawConfig().server.session_cookie_name, @@ -61,7 +64,7 @@ export async function traefikConfigProvider( return res.status(HttpCode.OK).json(traefikConfig); } catch (e) { - logger.error(`Failed to build Traefik config: ${e}`); + logger.error(e); return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ error: "Failed to build Traefik config" }); diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 388db4a31..88010e580 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, orgs, UserOrg } from "@server/db"; -import { roles, userInvites, userOrgs, users } from "@server/db"; -import { eq, and, inArray, ne } from "drizzle-orm"; +import { db, orgs } from "@server/db"; +import { roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -141,17 +141,34 @@ export async function acceptInvite( ); } - let roleId: number; - // get the role to make sure it exists - const existingRole = await db + const inviteRoleRows = await db + .select({ roleId: userInviteRoles.roleId }) + .from(userInviteRoles) + .where(eq(userInviteRoles.inviteId, inviteId)); + + const inviteRoleIds = [ + ...new Set(inviteRoleRows.map((r) => r.roleId)) + ]; + if (inviteRoleIds.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This invitation has no roles. Please contact an admin." + ) + ); + } + + const existingRoles = await db .select() .from(roles) - .where(eq(roles.roleId, existingInvite.roleId)) - .limit(1); - if (existingRole.length) { - roleId = existingRole[0].roleId; - } else { - // TODO: use the default role on the org instead of failing + .where( + and( + eq(roles.orgId, existingInvite.orgId), + inArray(roles.roleId, inviteRoleIds) + ) + ); + + if (existingRoles.length !== inviteRoleIds.length) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -165,9 +182,9 @@ export async function acceptInvite( org, { userId: existingUser[0].userId, - orgId: existingInvite.orgId, - roleId: existingInvite.roleId + orgId: existingInvite.orgId }, + inviteRoleIds, trx ); 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/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 3b8e70f7a..ddc37d3a2 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -6,8 +6,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { db, orgs, UserOrg } from "@server/db"; -import { and, eq, inArray, ne } from "drizzle-orm"; +import { db, orgs } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { generateId } from "@server/auth/sessions/app"; import { usageService } from "@server/lib/billing/usageService"; @@ -15,21 +15,43 @@ import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { assignUserToOrg } from "@server/lib/userOrg"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const bodySchema = z.strictObject({ - email: z.string().email().toLowerCase().optional(), - username: z.string().nonempty().toLowerCase(), - name: z.string().optional(), - type: z.enum(["internal", "oidc"]).optional(), - idpId: z.number().optional(), - roleId: z.number() -}); +const bodySchema = z + .strictObject({ + email: z.string().email().toLowerCase().optional(), + username: z.string().nonempty().toLowerCase(), + name: z.string().optional(), + type: z.enum(["internal", "oidc"]).optional(), + idpId: z.number().optional(), + roleIds: z.array(z.number().int().positive()).min(1).optional(), + roleId: z.number().int().positive().optional() + }) + .refine( + (d) => + (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, + { message: "roleIds or roleId is required", path: ["roleIds"] } + ) + .transform((data) => ({ + email: data.email, + username: data.username, + name: data.name, + type: data.type, + idpId: data.idpId, + roleIds: [ + ...new Set( + data.roleIds && data.roleIds.length > 0 + ? data.roleIds + : [data.roleId!] + ) + ] + })); export type CreateOrgUserResponse = {}; @@ -78,7 +100,8 @@ export async function createOrgUser( } const { orgId } = parsedParams.data; - const { username, email, name, type, idpId, roleId } = parsedBody.data; + const { username, email, name, type, idpId, roleIds: uniqueRoleIds } = + parsedBody.data; if (build == "saas") { const usage = await usageService.getUsage(orgId, FeatureId.USERS); @@ -109,17 +132,6 @@ export async function createOrgUser( } } - const [role] = await db - .select() - .from(roles) - .where(eq(roles.roleId, roleId)); - - if (!role) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Role ID not found") - ); - } - if (type === "internal") { return next( createHttpError( @@ -152,6 +164,38 @@ export async function createOrgUser( ); } + const supportsMultiRole = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.FullRbac] + ); + if (!supportsMultiRole && uniqueRoleIds.length > 1) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Multiple roles per user require a subscription or license that includes full RBAC." + ) + ); + } + + 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, + "Invalid role ID or role does not belong to this organization" + ) + ); + } + const [org] = await db .select() .from(orgs) @@ -221,12 +265,16 @@ export async function createOrgUser( ); } - await assignUserToOrg(org, { - orgId, - userId: existingUser.userId, - roleId: role.roleId, - autoProvisioned: false - }, trx); + await assignUserToOrg( + org, + { + orgId, + userId: existingUser.userId, + autoProvisioned: false, + }, + uniqueRoleIds, + trx + ); } else { userId = generateId(15); @@ -244,12 +292,16 @@ export async function createOrgUser( }) .returning(); - await assignUserToOrg(org, { - orgId, - userId: newUser.userId, - roleId: role.roleId, - autoProvisioned: false - }, trx); + await assignUserToOrg( + org, + { + orgId, + userId: newUser.userId, + autoProvisioned: false, + }, + uniqueRoleIds, + trx + ); } await calculateUserClientsForOrgs(userId, trx); diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index b117ca569..c415e186c 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idp, idpOidcConfig } from "@server/db"; -import { roles, userOrgs, users } from "@server/db"; +import { roles, userOrgRoles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -12,7 +12,7 @@ import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { OpenAPITags, registry } from "@server/openApi"; export async function queryUser(orgId: string, userId: string) { - const [user] = await db + const [userRow] = await db .select({ orgId: userOrgs.orgId, userId: users.userId, @@ -20,10 +20,7 @@ export async function queryUser(orgId: string, userId: string) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, - isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, autoProvisioned: userOrgs.autoProvisioned, idpId: users.idpId, @@ -33,13 +30,40 @@ export async function queryUser(orgId: string, userId: string) { idpAutoProvision: idp.autoProvision }) .from(userOrgs) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - return user; + + if (!userRow) return undefined; + + const roleRows = await db + .select({ + roleId: userOrgRoles.roleId, + roleName: roles.name, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const isAdmin = roleRows.some((r) => r.isAdmin); + + return { + ...userRow, + isAdmin, + roleIds: roleRows.map((r) => r.roleId), + roles: roleRows.map((r) => ({ + roleId: r.roleId, + name: r.roleName ?? "" + })) + }; } export type GetOrgUserResponse = NonNullable< diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index b6fb05d92..e03676caa 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -1,7 +1,8 @@ export * from "./getUser"; export * from "./removeUserOrg"; export * from "./listUsers"; -export * from "./addUserRole"; +export * from "./types"; +export * from "./addUserRoleLegacy"; export * from "./inviteUser"; export * from "./acceptInvite"; export * from "./getOrgUser"; diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index b0632da9e..7ac1849b9 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs, roles, userInvites, userOrgs, users } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } 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"; @@ -18,22 +18,44 @@ import { OpenAPITags, registry } from "@server/openApi"; import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { build } from "@server/build"; import cache from "#dynamic/lib/cache"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; const inviteUserParamsSchema = z.strictObject({ orgId: z.string() }); -const inviteUserBodySchema = z.strictObject({ - email: z.email().toLowerCase(), - roleId: z.number(), - validHours: z.number().gt(0).lte(168), - sendEmail: z.boolean().optional(), - regenerate: z.boolean().optional() -}); +const inviteUserBodySchema = z + .strictObject({ + email: z.email().toLowerCase(), + roleIds: z.array(z.number().int().positive()).min(1).optional(), + roleId: z.number().int().positive().optional(), + validHours: z.number().gt(0).lte(168), + sendEmail: z.boolean().optional(), + regenerate: z.boolean().optional() + }) + .refine( + (d) => + (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, + { message: "roleIds or roleId is required", path: ["roleIds"] } + ) + .transform((data) => ({ + email: data.email, + validHours: data.validHours, + sendEmail: data.sendEmail, + regenerate: data.regenerate, + roleIds: [ + ...new Set( + data.roleIds && data.roleIds.length > 0 + ? data.roleIds + : [data.roleId!] + ) + ] + })); -export type InviteUserBody = z.infer; +export type InviteUserBody = z.input; export type InviteUserResponse = { inviteLink: string; @@ -88,7 +110,7 @@ export async function inviteUser( const { email, validHours, - roleId, + roleIds: uniqueRoleIds, sendEmail: doEmail, regenerate } = parsedBody.data; @@ -105,14 +127,30 @@ export async function inviteUser( ); } - // Validate that the roleId belongs to the target organization - const [role] = await db - .select() - .from(roles) - .where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId))) - .limit(1); + const supportsMultiRole = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.FullRbac] + ); + if (!supportsMultiRole && uniqueRoleIds.length > 1) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Multiple roles per user require a subscription or license that includes full RBAC." + ) + ); + } - if (!role) { + 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, @@ -191,7 +229,8 @@ export async function inviteUser( } if (existingInvite.length) { - const attempts = (await cache.get(email)) || 0; + const attempts = + (await cache.get("regenerateInvite:" + email)) || 0; if (attempts >= 3) { return next( createHttpError( @@ -273,9 +312,11 @@ export async function inviteUser( orgId, email, expiresAt, - tokenHash, - roleId + tokenHash }); + await trx.insert(userInviteRoles).values( + uniqueRoleIds.map((roleId) => ({ inviteId, roleId })) + ); }); const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; diff --git a/server/routers/user/listInvitations.ts b/server/routers/user/listInvitations.ts index 2733c8395..1f4bcc02c 100644 --- a/server/routers/user/listInvitations.ts +++ b/server/routers/user/listInvitations.ts @@ -1,11 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userInvites, roles } from "@server/db"; +import { userInvites, userInviteRoles, roles } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql } from "drizzle-orm"; +import { sql, eq, and, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -29,24 +29,66 @@ const listInvitationsQuerySchema = z.strictObject({ .pipe(z.int().nonnegative()) }); -async function queryInvitations(orgId: string, limit: number, offset: number) { - return await db +export type InvitationListRow = { + inviteId: string; + email: string; + expiresAt: number; + roles: { roleId: number; roleName: string | null }[]; +}; + +async function queryInvitations( + orgId: string, + limit: number, + offset: number +): Promise { + const inviteRows = await db .select({ inviteId: userInvites.inviteId, email: userInvites.email, - expiresAt: userInvites.expiresAt, - roleId: userInvites.roleId, - roleName: roles.name + expiresAt: userInvites.expiresAt }) .from(userInvites) - .leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`) - .where(sql`${userInvites.orgId} = ${orgId}`) + .where(eq(userInvites.orgId, orgId)) .limit(limit) .offset(offset); + + if (inviteRows.length === 0) { + return []; + } + + const inviteIds = inviteRows.map((r) => r.inviteId); + const roleRows = await db + .select({ + inviteId: userInviteRoles.inviteId, + roleId: userInviteRoles.roleId, + roleName: roles.name + }) + .from(userInviteRoles) + .innerJoin(roles, eq(userInviteRoles.roleId, roles.roleId)) + .where( + and(eq(roles.orgId, orgId), inArray(userInviteRoles.inviteId, inviteIds)) + ); + + const rolesByInvite = new Map< + string, + { roleId: number; roleName: string | null }[] + >(); + for (const row of roleRows) { + const list = rolesByInvite.get(row.inviteId) ?? []; + list.push({ roleId: row.roleId, roleName: row.roleName }); + rolesByInvite.set(row.inviteId, list); + } + + return inviteRows.map((inv) => ({ + inviteId: inv.inviteId, + email: inv.email, + expiresAt: inv.expiresAt, + roles: rolesByInvite.get(inv.inviteId) ?? [] + })); } export type ListInvitationsResponse = { - invitations: NonNullable>>; + invitations: InvitationListRow[]; pagination: { total: number; limit: number; offset: number }; }; @@ -95,7 +137,7 @@ export async function listInvitations( const [{ count }] = await db .select({ count: sql`count(*)` }) .from(userInvites) - .where(sql`${userInvites.orgId} = ${orgId}`); + .where(eq(userInvites.orgId, orgId)); return response(res, { data: { diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 40ca7ef2f..fe7f6b250 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,15 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idpOidcConfig } from "@server/db"; -import { idp, roles, userOrgs, users } from "@server/db"; +import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq } from "drizzle-orm"; const listUsersParamsSchema = z.strictObject({ orgId: z.string() @@ -31,7 +30,7 @@ const listUsersSchema = z.strictObject({ }); async function queryUsers(orgId: string, limit: number, offset: number) { - return await db + const rows = await db .select({ id: users.userId, email: users.email, @@ -41,8 +40,6 @@ async function queryUsers(orgId: string, limit: number, offset: number) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, idpName: idp.name, idpId: users.idpId, @@ -52,12 +49,48 @@ async function queryUsers(orgId: string, limit: number, offset: number) { }) .from(users) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .where(eq(userOrgs.orgId, orgId)) .limit(limit) .offset(offset); + + const userIds = rows.map((r) => r.id); + const roleRows = + userIds.length === 0 + ? [] + : await db + .select({ + userId: userOrgRoles.userId, + roleId: userOrgRoles.roleId, + roleName: roles.name + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.orgId, orgId), + inArray(userOrgRoles.userId, userIds) + ) + ); + + const rolesByUser = new Map< + string, + { roleId: number; roleName: string }[] + >(); + for (const r of roleRows) { + const list = rolesByUser.get(r.userId) ?? []; + list.push({ roleId: r.roleId, roleName: r.roleName ?? "" }); + rolesByUser.set(r.userId, list); + } + + return rows.map((row) => { + const userRoles = rolesByUser.get(row.id) ?? []; + return { + ...row, + roles: userRoles + }; + }); } export type ListUsersResponse = { diff --git a/server/routers/user/myDevice.ts b/server/routers/user/myDevice.ts index 144108e11..3b991ca56 100644 --- a/server/routers/user/myDevice.ts +++ b/server/routers/user/myDevice.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { db, Olm, olms, orgs, userOrgs } from "@server/db"; +import { db, Olm, olms, orgs, userOrgRoles, userOrgs } from "@server/db"; import { idp, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -84,16 +84,31 @@ export async function myDevice( .from(olms) .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); - const userOrganizations = await db + const userOrgRows = await db .select({ orgId: userOrgs.orgId, - orgName: orgs.name, - roleId: userOrgs.roleId + orgName: orgs.name }) .from(userOrgs) .where(eq(userOrgs.userId, userId)) .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)); + const roleRows = await db + .select({ + orgId: userOrgRoles.orgId, + roleId: userOrgRoles.roleId + }) + .from(userOrgRoles) + .where(eq(userOrgRoles.userId, userId)); + + const roleByOrg = new Map( + roleRows.map((r) => [r.orgId, r.roleId]) + ); + const userOrganizations = userOrgRows.map((row) => ({ + ...row, + roleId: roleByOrg.get(row.orgId) ?? 0 + })); + return response(res, { data: { user, 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/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 1ace73474..9ba0b9767 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -21,6 +21,7 @@ import m12 from "./scriptsPg/1.15.0"; import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; +import m16 from "./scriptsPg/1.17.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -41,7 +42,8 @@ const migrations = [ { version: "1.15.0", run: m12 }, { version: "1.15.3", run: m13 }, { version: "1.15.4", run: m14 }, - { version: "1.16.0", run: m15 } + { version: "1.16.0", run: m15 }, + { version: "1.17.0", run: m16 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index da7e6b6d1..45a29ec29 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -39,6 +39,7 @@ import m33 from "./scriptsSqlite/1.15.0"; import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; +import m37 from "./scriptsSqlite/1.17.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -75,7 +76,8 @@ const migrations = [ { version: "1.15.0", run: m33 }, { version: "1.15.3", run: m34 }, { version: "1.15.4", run: m35 }, - { version: "1.16.0", run: m36 } + { version: "1.16.0", run: m36 }, + { version: "1.17.0", run: m37 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.17.0.ts b/server/setup/scriptsPg/1.17.0.ts new file mode 100644 index 000000000..81c42e1a9 --- /dev/null +++ b/server/setup/scriptsPg/1.17.0.ts @@ -0,0 +1,73 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.17.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Query existing roleId data from userOrgs before the transaction destroys it + const existingRolesQuery = await db.execute( + sql`SELECT "userId", "orgId", "roleId" FROM "userOrgs" WHERE "roleId" IS NOT NULL` + ); + const existingUserOrgRoles = existingRolesQuery.rows as { + userId: string; + orgId: string; + roleId: number; + }[]; + + console.log( + `Found ${existingUserOrgRoles.length} existing userOrgs role assignment(s) to migrate` + ); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "userOrgRoles" ( + "userId" varchar NOT NULL, + "orgId" varchar NOT NULL, + "roleId" integer NOT NULL, + CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId") + ); + `); + await db.execute(sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgs" DROP COLUMN "roleId";`); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + // Re-insert the preserved role assignments into the new userOrgRoles table + if (existingUserOrgRoles.length > 0) { + try { + for (const row of existingUserOrgRoles) { + await db.execute(sql` + INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId") + VALUES (${row.userId}, ${row.orgId}, ${row.roleId}) + ON CONFLICT DO NOTHING + `); + } + + console.log( + `Migrated ${existingUserOrgRoles.length} role assignment(s) into userOrgRoles` + ); + } catch (e) { + console.error( + "Error while migrating role assignments into userOrgRoles:", + e + ); + throw e; + } + } + + console.log(`${version} migration complete`); +} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.17.0.ts b/server/setup/scriptsSqlite/1.17.0.ts new file mode 100644 index 000000000..fe7d82de0 --- /dev/null +++ b/server/setup/scriptsSqlite/1.17.0.ts @@ -0,0 +1,96 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.17.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + // Query existing roleId data from userOrgs before the transaction destroys it + const existingUserOrgRoles = db + .prepare( + `SELECT "userId", "orgId", "roleId" FROM 'userOrgs' WHERE "roleId" IS NOT NULL` + ) + .all() as { userId: string; orgId: string; roleId: number }[]; + + console.log( + `Found ${existingUserOrgRoles.length} existing userOrgs role assignment(s) to migrate` + ); + + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'userOrgRoles' ( + 'userId' text NOT NULL, + 'orgId' text NOT NULL, + 'roleId' integer NOT NULL, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `CREATE UNIQUE INDEX 'userOrgRoles_userId_orgId_roleId_unique' ON 'userOrgRoles' ('userId','orgId','roleId');` + ).run(); + + db.prepare( + ` + CREATE TABLE '__new_userOrgs' ( + 'userId' text NOT NULL, + 'orgId' text NOT NULL, + 'isOwner' integer DEFAULT false NOT NULL, + 'autoProvisioned' integer DEFAULT false, + 'pamUsername' text, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';` + ).run(); + db.prepare(`DROP TABLE 'userOrgs';`).run(); + db.prepare( + `ALTER TABLE '__new_userOrgs' RENAME TO 'userOrgs';` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + // Re-insert the preserved role assignments into the new userOrgRoles table + if (existingUserOrgRoles.length > 0) { + const insertUserOrgRole = db.prepare( + `INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)` + ); + + const insertAll = db.transaction(() => { + for (const row of existingUserOrgRoles) { + insertUserOrgRole.run(row.userId, row.orgId, row.roleId); + } + }); + + insertAll(); + + console.log( + `Migrated ${existingUserOrgRoles.length} role assignment(s) into userOrgRoles` + ); + } + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} \ No newline at end of file diff --git a/server/types/Auth.ts b/server/types/Auth.ts index 8e222987c..398c02406 100644 --- a/server/types/Auth.ts +++ b/server/types/Auth.ts @@ -5,5 +5,5 @@ import { Session } from "@server/db"; export interface AuthenticatedRequest extends Request { user: User; session: Session; - userOrgRoleId?: number; + userOrgRoleIds?: number[]; } diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 7d4bece1e..37334e342 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -45,8 +45,17 @@ import { useTranslations } from "next-intl"; import { AxiosResponse } from "axios"; import { ListRolesResponse } from "@server/routers/role"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + compileRoleMappingExpression, + createMappingBuilderRule, + detectRoleMappingConfig, + ensureMappingBuilderRuleIds, + MappingBuilderRule, + RoleMappingMode +} from "@app/lib/idpRoleMapping"; export default function GeneralPage() { const { env } = useEnvContext(); @@ -56,9 +65,15 @@ export default function GeneralPage() { const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - const [roleMappingMode, setRoleMappingMode] = useState< - "role" | "expression" - >("role"); + const [roleMappingMode, setRoleMappingMode] = + useState("fixedRoles"); + const [fixedRoleNames, setFixedRoleNames] = useState([]); + const [mappingBuilderClaimPath, setMappingBuilderClaimPath] = + useState("groups"); + const [mappingBuilderRules, setMappingBuilderRules] = useState< + MappingBuilderRule[] + >([createMappingBuilderRule()]); + const [rawRoleExpression, setRawRoleExpression] = useState(""); const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; @@ -190,34 +205,8 @@ export default function GeneralPage() { // Set the variant setVariant(idpVariant as "oidc" | "google" | "azure"); - // Check if roleMapping matches the basic pattern '{role name}' (simple single role) - // This should NOT match complex expressions like 'Admin' || 'Member' - const isBasicRolePattern = - roleMapping && - typeof roleMapping === "string" && - /^'[^']+'$/.test(roleMapping); - - // Determine if roleMapping is a number (roleId) or matches basic pattern - const isRoleId = - !isNaN(Number(roleMapping)) && roleMapping !== ""; - const isRoleName = isBasicRolePattern; - - // Extract role name from basic pattern for matching - let extractedRoleName = null; - if (isRoleName) { - extractedRoleName = roleMapping.slice(1, -1); // Remove quotes - } - - // Try to find matching role by name if we have a basic pattern - let matchingRoleId = undefined; - if (extractedRoleName && availableRoles.length > 0) { - const matchingRole = availableRoles.find( - (role) => role.name === extractedRoleName - ); - if (matchingRole) { - matchingRoleId = matchingRole.roleId; - } - } + const detectedRoleMappingConfig = + detectRoleMappingConfig(roleMapping); // Extract tenant ID from Azure URLs if present let tenantId = ""; @@ -238,9 +227,7 @@ export default function GeneralPage() { clientSecret: data.idpOidcConfig.clientSecret, autoProvision: data.idp.autoProvision, roleMapping: roleMapping || null, - roleId: isRoleId - ? Number(roleMapping) - : matchingRoleId || null + roleId: null }; // Add variant-specific fields @@ -259,10 +246,18 @@ export default function GeneralPage() { form.reset(formData); - // Set the role mapping mode based on the data - // Default to "expression" unless it's a simple roleId or basic '{role name}' pattern - setRoleMappingMode( - matchingRoleId && isRoleName ? "role" : "expression" + setRoleMappingMode(detectedRoleMappingConfig.mode); + setFixedRoleNames(detectedRoleMappingConfig.fixedRoleNames); + setMappingBuilderClaimPath( + detectedRoleMappingConfig.mappingBuilder.claimPath + ); + setMappingBuilderRules( + ensureMappingBuilderRuleIds( + detectedRoleMappingConfig.mappingBuilder.rules + ) + ); + setRawRoleExpression( + detectedRoleMappingConfig.rawExpression ); } } catch (e) { @@ -327,7 +322,26 @@ export default function GeneralPage() { return; } - const roleName = roles.find((r) => r.roleId === data.roleId)?.name; + const roleMappingExpression = compileRoleMappingExpression({ + mode: roleMappingMode, + fixedRoleNames, + mappingBuilder: { + claimPath: mappingBuilderClaimPath, + rules: mappingBuilderRules + }, + rawExpression: rawRoleExpression + }); + + if (data.autoProvision && !roleMappingExpression) { + toast({ + title: t("error"), + description: + "A role mapping is required when auto-provisioning is enabled.", + variant: "destructive" + }); + setLoading(false); + return; + } // Build payload based on variant let payload: any = { @@ -335,10 +349,7 @@ export default function GeneralPage() { clientId: data.clientId, clientSecret: data.clientSecret, autoProvision: data.autoProvision, - roleMapping: - roleMappingMode === "role" - ? `'${roleName}'` - : data.roleMapping || "" + roleMapping: roleMappingExpression }; // Add variant-specific fields @@ -438,16 +449,6 @@ export default function GeneralPage() { - - - - {t("redirectUrlAbout")} - - - {t("redirectUrlAboutDescription")} - - - {/* IDP Type Indicator */} @@ -493,46 +494,47 @@ export default function GeneralPage() { {t("idpAutoProvisionUsers")} - {t("idpAutoProvisionUsersDescription")} + - - + - - - { - form.setValue( - "autoProvision", - checked - ); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - // Clear roleId and roleMapping when mode changes - form.setValue("roleId", null); - form.setValue("roleMapping", null); - }} - roles={roles} - roleIdFieldName="roleId" - roleMappingFieldName="roleMapping" - /> - - - + + + { + form.setValue("autoProvision", checked); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={setFixedRoleNames} + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={mappingBuilderRules} + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={setRawRoleExpression} + /> + + @@ -832,29 +834,6 @@ export default function GeneralPage() { className="space-y-4" id="general-settings-form" > - - - - {t("idpJmespathAbout")} - - - {t( - "idpJmespathAboutDescription" - )}{" "} - - {t( - "idpJmespathAboutDescriptionLink" - )}{" "} - - - - - ([]); - const [roleMappingMode, setRoleMappingMode] = useState< - "role" | "expression" - >("role"); + const [roleMappingMode, setRoleMappingMode] = + useState("fixedRoles"); + const [fixedRoleNames, setFixedRoleNames] = useState([]); + const [mappingBuilderClaimPath, setMappingBuilderClaimPath] = + useState("groups"); + const [mappingBuilderRules, setMappingBuilderRules] = useState< + MappingBuilderRule[] + >([createMappingBuilderRule()]); + const [rawRoleExpression, setRawRoleExpression] = useState(""); const t = useTranslations(); const { isPaidUser } = usePaidStatus(); @@ -84,49 +96,6 @@ export default function Page() { type CreateIdpFormValues = z.infer; - interface ProviderTypeOption { - id: "oidc" | "google" | "azure"; - title: string; - description: string; - icon?: React.ReactNode; - } - - const providerTypes: ReadonlyArray = [ - { - id: "oidc", - title: "OAuth2/OIDC", - description: t("idpOidcDescription") - }, - { - id: "google", - title: t("idpGoogleTitle"), - description: t("idpGoogleDescription"), - icon: ( - - ) - }, - { - id: "azure", - title: t("idpAzureTitle"), - description: t("idpAzureDescription"), - icon: ( - - ) - } - ]; - const form = useForm({ resolver: zodResolver(createIdpFormSchema), defaultValues: { @@ -174,47 +143,6 @@ export default function Page() { fetchRoles(); }, []); - // Handle provider type changes and set defaults - const handleProviderChange = (value: "oidc" | "google" | "azure") => { - form.setValue("type", value); - - if (value === "google") { - // Set Google defaults - form.setValue( - "authUrl", - "https://accounts.google.com/o/oauth2/v2/auth" - ); - form.setValue("tokenUrl", "https://oauth2.googleapis.com/token"); - form.setValue("identifierPath", "email"); - form.setValue("emailPath", "email"); - form.setValue("namePath", "name"); - form.setValue("scopes", "openid profile email"); - } else if (value === "azure") { - // Set Azure Entra ID defaults (URLs will be constructed dynamically) - form.setValue( - "authUrl", - "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize" - ); - form.setValue( - "tokenUrl", - "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token" - ); - form.setValue("identifierPath", "email"); - form.setValue("emailPath", "email"); - form.setValue("namePath", "name"); - form.setValue("scopes", "openid profile email"); - form.setValue("tenantId", ""); - } else { - // Reset to OIDC defaults - form.setValue("authUrl", ""); - form.setValue("tokenUrl", ""); - form.setValue("identifierPath", "sub"); - form.setValue("namePath", "name"); - form.setValue("emailPath", "email"); - form.setValue("scopes", "openid profile email"); - } - }; - async function onSubmit(data: CreateIdpFormValues) { setCreateLoading(true); @@ -228,7 +156,26 @@ export default function Page() { tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId); } - const roleName = roles.find((r) => r.roleId === data.roleId)?.name; + const roleMappingExpression = compileRoleMappingExpression({ + mode: roleMappingMode, + fixedRoleNames, + mappingBuilder: { + claimPath: mappingBuilderClaimPath, + rules: mappingBuilderRules + }, + rawExpression: rawRoleExpression + }); + + if (data.autoProvision && !roleMappingExpression) { + toast({ + title: t("error"), + description: + "A role mapping is required when auto-provisioning is enabled.", + variant: "destructive" + }); + setCreateLoading(false); + return; + } const payload = { name: data.name, @@ -240,10 +187,7 @@ export default function Page() { emailPath: data.emailPath, namePath: data.namePath, autoProvision: data.autoProvision, - roleMapping: - roleMappingMode === "role" - ? `'${roleName}'` - : data.roleMapping || "", + roleMapping: roleMappingExpression, scopes: data.scopes, variant: data.type }; @@ -308,23 +252,12 @@ export default function Page() { - - - - {t("idpType")} - - - { - handleProviderChange( - value as "oidc" | "google" | "azure" - ); - }} - cols={3} - /> - + { + applyOidcIdpProviderType(form.setValue, next); + }} + /> @@ -364,47 +297,48 @@ export default function Page() { {t("idpAutoProvisionUsers")} - {t("idpAutoProvisionUsersDescription")} + - - - - - { - form.setValue( - "autoProvision", - checked - ); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - // Clear roleId and roleMapping when mode changes - form.setValue("roleId", null); - form.setValue("roleMapping", null); - }} - roles={roles} - roleIdFieldName="roleId" - roleMappingFieldName="roleMapping" - /> - - - + + + + { + form.setValue("autoProvision", checked); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={setFixedRoleNames} + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={mappingBuilderRules} + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={setRawRoleExpression} + /> + + @@ -679,16 +613,6 @@ export default function Page() { /> - - - - - {t("idpOidcConfigureAlert")} - - - {t("idpOidcConfigureAlertDescription")} - - diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx index b6ee14484..00cb0ffc8 100644 --- a/src/app/[orgId]/settings/access/invitations/page.tsx +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -29,9 +29,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) { let invitations: { inviteId: string; email: string; - expiresAt: string; - roleId: number; - roleName?: string; + expiresAt: number; + roles: { roleId: number; roleName: string | null }[]; }[] = []; let hasInvitations = false; @@ -66,12 +65,15 @@ export default async function InvitationsPage(props: InvitationsPageProps) { } const invitationRows: InvitationRow[] = invitations.map((invite) => { + const names = invite.roles + .map((r) => r.roleName || t("accessRoleUnknown")) + .filter(Boolean); return { id: invite.inviteId, email: invite.email, expiresAt: new Date(Number(invite.expiresAt)).toISOString(), - role: invite.roleName || t("accessRoleUnknown"), - roleId: invite.roleId + roleLabels: names, + roleIds: invite.roles.map((r) => r.roleId) }; }); 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 6313d512a..9ab9e93fa 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 @@ -8,18 +8,10 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; import { Checkbox } from "@app/components/ui/checkbox"; +import OrgRolesTagField from "@app/components/OrgRolesTagField"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -44,34 +36,69 @@ 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(), + autoProvisioned: z.boolean(), + roles: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ) +}); export default function AccessControlsPage() { - const { orgUser: user } = userOrgUserContext(); + const { orgUser: user, updateOrgUser } = userOrgUserContext(); + const { env } = useEnvContext(); - const api = createApiClient(useEnvContext()); + const api = createApiClient({ env }); const { orgId } = useParams(); const [loading, setLoading] = useState(false); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [activeRoleTagIndex, setActiveRoleTagIndex] = useState( + null + ); const t = useTranslations(); - - const formSchema = z.object({ - username: z.string(), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }), - autoProvisioned: z.boolean() - }); + const { isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.fullRbac); + const supportsMultipleRolesPerUser = isPaid; + const showMultiRolePaywallMessage = + !env.flags.disableEnterpriseFeatures && + ((build === "saas" && !isPaid) || + (build === "enterprise" && !isPaid) || + (build === "oss" && !isPaid)); const form = useForm({ - resolver: zodResolver(formSchema), + resolver: zodResolver(accessControlsFormSchema), defaultValues: { username: user.username!, - roleId: user.roleId?.toString(), - autoProvisioned: user.autoProvisioned || false + autoProvisioned: user.autoProvisioned || false, + roles: (user.roles ?? []).map((r) => ({ + id: r.roleId.toString(), + text: r.name + })) } }); + const currentRoleIds = user.roleIds ?? []; + + useEffect(() => { + form.setValue( + "roles", + (user.roles ?? []).map((r) => ({ + id: r.roleId.toString(), + text: r.name + })) + ); + }, [user.userId, currentRoleIds.join(",")]); + useEffect(() => { async function fetchRoles() { const res = await api @@ -94,32 +121,59 @@ export default function AccessControlsPage() { } fetchRoles(); - - form.setValue("roleId", user.roleId.toString()); form.setValue("autoProvisioned", user.autoProvisioned || false); }, []); - async function onSubmit(values: z.infer) { - setLoading(true); + const allRoleOptions = roles.map((role) => ({ + id: role.roleId.toString(), + text: role.name + })); + const paywallMessage = + build === "saas" + ? t("singleRolePerUserPlanNotice") + : t("singleRolePerUserEditionNotice"); + + async function onSubmit(values: z.infer) { + if (values.roles.length === 0) { + toast({ + variant: "destructive", + title: t("accessRoleErrorAdd"), + description: t("accessRoleSelectPlease") + }); + return; + } + + setLoading(true); try { - // Execute both API calls simultaneously - const [roleRes, userRes] = await Promise.all([ - api.post>( - `/role/${values.roleId}/add/${user.userId}` - ), + 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([ + updateRoleRequest, api.post(`/org/${orgId}/user/${user.userId}`, { autoProvisioned: values.autoProvisioned }) ]); - if (roleRes.status === 200 && userRes.status === 200) { - toast({ - variant: "default", - title: t("userSaved"), - description: t("userSavedDescription") - }); - } + updateOrgUser({ + roleIds, + roles: values.roles.map((r) => ({ + roleId: parseInt(r.id, 10), + name: r.text + })), + autoProvisioned: values.autoProvisioned + }); + + toast({ + variant: "default", + title: t("userSaved"), + description: t("userSavedDescription") + }); } catch (e) { toast({ variant: "destructive", @@ -130,7 +184,6 @@ export default function AccessControlsPage() { ) }); } - setLoading(false); } @@ -154,7 +207,6 @@ export default function AccessControlsPage() { className="space-y-4" id="access-controls-form" > - {/* IDP Type Display */} {user.type !== UserType.Internal && user.idpType && ( @@ -171,48 +223,22 @@ export default function AccessControlsPage() { )} - ( - - {t("role")} - { - field.onChange(value); - // If auto provision is enabled, set it to false when role changes - if (user.idpAutoProvision) { - form.setValue( - "autoProvisioned", - false - ); - } - }} - value={field.value} - > - - - - - - - {roles.map((role) => ( - - {role.name} - - ))} - - - - - )} + {user.idpAutoProvision && ( diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 08737f5e2..0263d2b72 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -32,7 +32,7 @@ import { } from "@app/components/ui/select"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; +import { InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -49,6 +49,7 @@ import { build } from "@server/build"; import Image from "next/image"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import OrgRolesTagField from "@app/components/OrgRolesTagField"; type UserType = "internal" | "oidc"; @@ -76,7 +77,14 @@ export default function Page() { const api = createApiClient({ env }); const t = useTranslations(); - const { hasSaasSubscription } = usePaidStatus(); + const { hasSaasSubscription, isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.fullRbac); + const supportsMultipleRolesPerUser = isPaid; + const showMultiRolePaywallMessage = + !env.flags.disableEnterpriseFeatures && + ((build === "saas" && !isPaid) || + (build === "enterprise" && !isPaid) || + (build === "oss" && !isPaid)); const [selectedOption, setSelectedOption] = useState( "internal" @@ -89,19 +97,34 @@ export default function Page() { const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); const [userOptions, setUserOptions] = useState([]); const [dataLoaded, setDataLoaded] = useState(false); + const [activeInviteRoleTagIndex, setActiveInviteRoleTagIndex] = useState< + number | null + >(null); + const [activeOidcRoleTagIndex, setActiveOidcRoleTagIndex] = useState< + number | null + >(null); + + const roleTagsFieldSchema = z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .min(1, { message: t("accessRoleSelectPlease") }); const internalFormSchema = z.object({ email: z.email({ message: t("emailInvalid") }), validForHours: z .string() .min(1, { message: t("inviteValidityDuration") }), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) + roles: roleTagsFieldSchema }); const googleAzureFormSchema = z.object({ email: z.email({ message: t("emailInvalid") }), name: z.string().optional(), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) + roles: roleTagsFieldSchema }); const genericOidcFormSchema = z.object({ @@ -111,7 +134,7 @@ export default function Page() { .optional() .or(z.literal("")), name: z.string().optional(), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) + roles: roleTagsFieldSchema }); const formatIdpType = (type: string) => { @@ -166,12 +189,22 @@ export default function Page() { { hours: 168, name: t("day", { count: 7 }) } ]; + const allRoleOptions = roles.map((role) => ({ + id: role.roleId.toString(), + text: role.name + })); + + const invitePaywallMessage = + build === "saas" + ? t("singleRolePerUserPlanNotice") + : t("singleRolePerUserEditionNotice"); + const internalForm = useForm({ resolver: zodResolver(internalFormSchema), defaultValues: { email: "", validForHours: "72", - roleId: "" + roles: [] as { id: string; text: string }[] } }); @@ -180,7 +213,7 @@ export default function Page() { defaultValues: { email: "", name: "", - roleId: "" + roles: [] as { id: string; text: string }[] } }); @@ -190,7 +223,7 @@ export default function Page() { username: "", email: "", name: "", - roleId: "" + roles: [] as { id: string; text: string }[] } }); @@ -305,16 +338,17 @@ export default function Page() { ) { setLoading(true); - const res = await api - .post>( - `/org/${orgId}/create-invite`, - { - email: values.email, - roleId: parseInt(values.roleId), - validHours: parseInt(values.validForHours), - sendEmail: sendEmail - } as InviteUserBody - ) + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + + const res = await api.post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleIds, + validHours: parseInt(values.validForHours), + sendEmail + } + ) .catch((e) => { if (e.response?.status === 409) { toast({ @@ -358,6 +392,8 @@ export default function Page() { setLoading(true); + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + const res = await api .put(`/org/${orgId}/user`, { username: values.email, // Use email as username for Google/Azure @@ -365,7 +401,7 @@ export default function Page() { name: values.name, type: "oidc", idpId: selectedUserOption.idpId, - roleId: parseInt(values.roleId) + roleIds }) .catch((e) => { toast({ @@ -400,6 +436,8 @@ export default function Page() { setLoading(true); + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + const res = await api .put(`/org/${orgId}/user`, { username: values.username, @@ -407,7 +445,7 @@ export default function Page() { name: values.name, type: "oidc", idpId: selectedUserOption.idpId, - roleId: parseInt(values.roleId) + roleIds }) .catch((e) => { toast({ @@ -575,52 +613,32 @@ export default function Page() { )} /> - ( - - - {t("role")} - - - - - - - - - {roles.map( - ( - role - ) => ( - - { - role.name - } - - ) - )} - - - - + {env.email.emailEnabled && ( @@ -764,52 +782,32 @@ export default function Page() { )} /> - ( - - - {t("role")} - - - - - - - - - {roles.map( - ( - role - ) => ( - - { - role.name - } - - ) - )} - - - - + @@ -909,52 +907,32 @@ export default function Page() { )} /> - ( - - - {t("role")} - - - - - - - - - {roles.map( - ( - role - ) => ( - - { - role.name - } - - ) - )} - - - - + diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index c10363734..812ac2b64 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -86,9 +86,14 @@ export default async function UsersPage(props: UsersPageProps) { idpId: user.idpId, idpName: user.idpName || t("idpNameInternal"), status: t("userConfirmed"), - role: user.isOwner - ? t("accessRoleOwner") - : user.roleName || t("accessRoleMember"), + roleLabels: user.isOwner + ? [t("accessRoleOwner")] + : (() => { + const names = (user.roles ?? []) + .map((r) => r.roleName) + .filter((n): n is string => Boolean(n?.length)); + return names.length ? names : [t("accessRoleMember")]; + })(), isOwner: user.isOwner || false }; }); diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index a533fb6c3..414a9b652 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -129,12 +129,13 @@ export default function ResourceAuthenticationPage() { orgId: org.org.orgId }) ); - const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( - orgQueries.identityProviders({ + const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery({ + ...orgQueries.identityProviders({ orgId: org.org.orgId, useOrgOnlyIdp: env.app.identityProviderMode === "org" - }) - ); + }), + enabled: isPaidUser(tierMatrix.orgOidc) + }); const pageLoading = isLoadingOrgRoles || diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx index d431efa2d..c9506b027 100644 --- a/src/app/admin/idp/[idpId]/general/page.tsx +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -15,7 +15,8 @@ import { import { Input } from "@app/components/ui/input"; import { useForm } from "react-hook-form"; import { toast } from "@app/hooks/useToast"; -import { useRouter, useParams, redirect } from "next/navigation"; +import { useRouter, useParams } from "next/navigation"; +import Link from "next/link"; import { SettingsContainer, SettingsSection, @@ -24,16 +25,14 @@ import { SettingsSectionDescription, SettingsSectionBody, SettingsSectionForm, - SettingsSectionFooter, SettingsSectionGrid } from "@app/components/Settings"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useState, useEffect } from "react"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { SwitchInput } from "@app/components/SwitchInput"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon, ExternalLink } from "lucide-react"; import { InfoSection, InfoSectionContent, @@ -41,8 +40,7 @@ import { InfoSectionTitle } from "@app/components/InfoSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import { Badge } from "@app/components/ui/badge"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import IdpTypeBadge from "@app/components/IdpTypeBadge"; import { useTranslations } from "next-intl"; export default function GeneralPage() { @@ -52,12 +50,12 @@ export default function GeneralPage() { const { idpId } = useParams(); const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); - const { isUnlocked } = useLicenseStatusContext(); + const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; const t = useTranslations(); - const GeneralFormSchema = z.object({ + const OidcFormSchema = z.object({ name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), clientId: z.string().min(1, { message: t("idpClientIdRequired") }), clientSecret: z @@ -72,10 +70,46 @@ export default function GeneralPage() { autoProvision: z.boolean().default(false) }); - type GeneralFormValues = z.infer; + const GoogleFormSchema = z.object({ + name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), + clientId: z.string().min(1, { message: t("idpClientIdRequired") }), + clientSecret: z + .string() + .min(1, { message: t("idpClientSecretRequired") }), + autoProvision: z.boolean().default(false) + }); - const form = useForm({ - resolver: zodResolver(GeneralFormSchema), + const AzureFormSchema = z.object({ + name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), + clientId: z.string().min(1, { message: t("idpClientIdRequired") }), + clientSecret: z + .string() + .min(1, { message: t("idpClientSecretRequired") }), + tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }), + autoProvision: z.boolean().default(false) + }); + + type OidcFormValues = z.infer; + type GoogleFormValues = z.infer; + type AzureFormValues = z.infer; + type GeneralFormValues = + | OidcFormValues + | GoogleFormValues + | AzureFormValues; + + const getFormSchema = () => { + switch (variant) { + case "google": + return GoogleFormSchema; + case "azure": + return AzureFormSchema; + default: + return OidcFormSchema; + } + }; + + const form = useForm({ + resolver: zodResolver(getFormSchema()) as never, defaultValues: { name: "", clientId: "", @@ -86,28 +120,60 @@ export default function GeneralPage() { emailPath: "email", namePath: "name", scopes: "openid profile email", - autoProvision: true + autoProvision: true, + tenantId: "" } }); + useEffect(() => { + form.clearErrors(); + }, [variant, form]); + useEffect(() => { const loadIdp = async () => { try { const res = await api.get(`/idp/${idpId}`); if (res.status === 200) { const data = res.data.data; - form.reset({ + const idpVariant = + (data.idpOidcConfig?.variant as + | "oidc" + | "google" + | "azure") || "oidc"; + setVariant(idpVariant); + + let tenantId = ""; + if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) { + const tenantMatch = data.idpOidcConfig.authUrl.match( + /login\.microsoftonline\.com\/([^/]+)\/oauth2/ + ); + if (tenantMatch) { + tenantId = tenantMatch[1]; + } + } + + const formData: Record = { name: data.idp.name, clientId: data.idpOidcConfig.clientId, clientSecret: data.idpOidcConfig.clientSecret, - authUrl: data.idpOidcConfig.authUrl, - tokenUrl: data.idpOidcConfig.tokenUrl, - identifierPath: data.idpOidcConfig.identifierPath, - emailPath: data.idpOidcConfig.emailPath, - namePath: data.idpOidcConfig.namePath, - scopes: data.idpOidcConfig.scopes, autoProvision: data.idp.autoProvision - }); + }; + + if (idpVariant === "oidc") { + formData.authUrl = data.idpOidcConfig.authUrl; + formData.tokenUrl = data.idpOidcConfig.tokenUrl; + formData.identifierPath = + data.idpOidcConfig.identifierPath; + formData.emailPath = + data.idpOidcConfig.emailPath ?? undefined; + formData.namePath = + data.idpOidcConfig.namePath ?? undefined; + formData.scopes = data.idpOidcConfig.scopes; + } else if (idpVariant === "azure") { + formData.tenantId = tenantId; + } + + form.reset(formData as GeneralFormValues); } } catch (e) { toast({ @@ -122,25 +188,76 @@ export default function GeneralPage() { }; loadIdp(); - }, [idpId, api, form, router]); + }, [idpId]); async function onSubmit(data: GeneralFormValues) { setLoading(true); try { - const payload = { + const schema = getFormSchema(); + const validationResult = schema.safeParse(data); + + if (!validationResult.success) { + const errors = validationResult.error.flatten().fieldErrors; + Object.keys(errors).forEach((key) => { + const fieldName = key as keyof GeneralFormValues; + const errorMessage = + (errors as Record)[ + key + ]?.[0] || t("invalidValue"); + form.setError(fieldName, { + type: "manual", + message: errorMessage + }); + }); + setLoading(false); + return; + } + + let payload: Record = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, - authUrl: data.authUrl, - tokenUrl: data.tokenUrl, - identifierPath: data.identifierPath, - emailPath: data.emailPath, - namePath: data.namePath, autoProvision: data.autoProvision, - scopes: data.scopes + variant }; + if (variant === "oidc") { + const oidcData = data as OidcFormValues; + payload = { + ...payload, + authUrl: oidcData.authUrl, + tokenUrl: oidcData.tokenUrl, + identifierPath: oidcData.identifierPath, + emailPath: oidcData.emailPath ?? "", + namePath: oidcData.namePath ?? "", + scopes: oidcData.scopes + }; + } else if (variant === "azure") { + const azureData = data as AzureFormValues; + const authUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/authorize`; + const tokenUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/token`; + payload = { + ...payload, + authUrl, + tokenUrl, + identifierPath: "email", + emailPath: "email", + namePath: "name", + scopes: "openid profile email" + }; + } else if (variant === "google") { + payload = { + ...payload, + authUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + identifierPath: "email", + emailPath: "email", + namePath: "name", + scopes: "openid profile email" + }; + } + const res = await api.post(`/idp/${idpId}/oidc`, payload); if (res.status === 200) { @@ -189,15 +306,13 @@ export default function GeneralPage() { - - - - {t("redirectUrlAbout")} - - - {t("redirectUrlAboutDescription")} - - + + + {t("idpTypeLabel")}: + + + + )} /> - - - { - form.setValue( - "autoProvision", - checked - ); - }} - /> - - - {t("idpAutoProvisionUsersDescription")} - - + + + + {t("idpAutoProvisionUsers")} + + + + + + + + + + { + form.setValue( + "autoProvision", + checked + ); + }} + /> + + + {form.watch("autoProvision") && ( + + {t.rich( + "idpAdminAutoProvisionPoliciesTabHint", + { + policiesTabLink: ( + chunks + ) => ( + + {chunks} + + ) + } + )} + + )} + + + + + + + {variant === "google" && ( - {t("idpOidcConfigure")} + {t("idpGoogleConfiguration")} - {t("idpOidcConfigureDescription")} + {t("idpGoogleConfigurationDescription")} @@ -279,7 +432,7 @@ export default function GeneralPage() { {t( - "idpClientIdDescription" + "idpGoogleClientIdDescription" )} @@ -303,49 +456,7 @@ export default function GeneralPage() { {t( - "idpClientSecretDescription" - )} - - - - )} - /> - - ( - - - {t("idpAuthUrl")} - - - - - - {t( - "idpAuthUrlDescription" - )} - - - - )} - /> - - ( - - - {t("idpTokenUrl")} - - - - - - {t( - "idpTokenUrlDescription" + "idpGoogleClientSecretDescription" )} @@ -357,14 +468,16 @@ export default function GeneralPage() { + )} + {variant === "azure" && ( - {t("idpToken")} + {t("idpAzureConfiguration")} - {t("idpTokenDescription")} + {t("idpAzureConfigurationDescription")} @@ -375,43 +488,20 @@ export default function GeneralPage() { className="space-y-4" id="general-settings-form" > - - - - {t("idpJmespathAbout")} - - - {t( - "idpJmespathAboutDescription" - )} - - {t( - "idpJmespathAboutDescriptionLink" - )}{" "} - - - - - ( - {t("idpJmespathLabel")} + {t("idpTenantId")} {t( - "idpJmespathLabelDescription" + "idpAzureTenantIdDescription" )} @@ -421,20 +511,18 @@ export default function GeneralPage() { ( - {t( - "idpJmespathEmailPathOptional" - )} + {t("idpClientId")} {t( - "idpJmespathEmailPathOptionalDescription" + "idpAzureClientIdDescription" )} @@ -444,43 +532,21 @@ export default function GeneralPage() { ( - {t( - "idpJmespathNamePathOptional" - )} + {t("idpClientSecret")} - + {t( - "idpJmespathNamePathOptionalDescription" - )} - - - - )} - /> - - ( - - - {t( - "idpOidcConfigureScopes" - )} - - - - - - {t( - "idpOidcConfigureScopesDescription" + "idpAzureClientSecretDescription" )} @@ -492,15 +558,263 @@ export default function GeneralPage() { - + )} + + {variant === "oidc" && ( + + + + + {t("idpOidcConfigure")} + + + {t("idpOidcConfigureDescription")} + + + + + + + ( + + + {t("idpClientId")} + + + + + + {t( + "idpClientIdDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpClientSecretDescription" + )} + + + + )} + /> + + ( + + + {t("idpAuthUrl")} + + + + + + {t( + "idpAuthUrlDescription" + )} + + + + )} + /> + + ( + + + {t("idpTokenUrl")} + + + + + + {t( + "idpTokenUrlDescription" + )} + + + + )} + /> + + + + + + + + + + {t("idpToken")} + + + {t("idpTokenDescription")} + + + + + + + ( + + + {t( + "idpJmespathLabel" + )} + + + + + + {t( + "idpJmespathLabelDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathEmailPathOptional" + )} + + + + + + {t( + "idpJmespathEmailPathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathNamePathOptional" + )} + + + + + + {t( + "idpJmespathNamePathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpOidcConfigureScopes" + )} + + + + + + {t( + "idpOidcConfigureScopesDescription" + )} + + + + )} + /> + + + + + + + )} { + form.handleSubmit(onSubmit)(); + }} > {t("saveGeneralSettings")} diff --git a/src/app/admin/idp/[idpId]/layout.tsx b/src/app/admin/idp/[idpId]/layout.tsx index 9634a3de2..93ca08bd7 100644 --- a/src/app/admin/idp/[idpId]/layout.tsx +++ b/src/app/admin/idp/[idpId]/layout.tsx @@ -34,7 +34,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { href: `/admin/idp/${params.idpId}/general` }, { - title: t("orgPolicies"), + title: t("autoProvisionSettings"), href: `/admin/idp/${params.idpId}/policies` } ]; diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx index bf17abe98..57ee3cf7b 100644 --- a/src/app/admin/idp/[idpId]/policies/page.tsx +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; @@ -34,6 +34,7 @@ import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react"; import PolicyTable, { PolicyRow } from "../../../../../components/PolicyTable"; import { AxiosResponse } from "axios"; import { ListOrgsResponse } from "@server/routers/org"; +import { ListRolesResponse } from "@server/routers/role"; import { Popover, PopoverContent, @@ -50,8 +51,6 @@ import { } from "@app/components/ui/command"; import { CaretSortIcon } from "@radix-ui/react-icons"; import Link from "next/link"; -import { Textarea } from "@app/components/ui/textarea"; -import { InfoPopup } from "@app/components/ui/info-popup"; import { GetIdpResponse } from "@server/routers/idp"; import { SettingsContainer, @@ -64,16 +63,40 @@ import { SettingsSectionForm } from "@app/components/Settings"; import { useTranslations } from "next-intl"; +import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; +import { + compileRoleMappingExpression, + createMappingBuilderRule, + defaultRoleMappingConfig, + detectRoleMappingConfig, + MappingBuilderRule, + RoleMappingMode +} from "@app/lib/idpRoleMapping"; type Organization = { orgId: string; name: string; }; +function resetRoleMappingStateFromDetected( + setMode: (m: RoleMappingMode) => void, + setFixed: (v: string[]) => void, + setClaim: (v: string) => void, + setRules: (v: MappingBuilderRule[]) => void, + setRaw: (v: string) => void, + stored: string | null | undefined +) { + const d = detectRoleMappingConfig(stored); + setMode(d.mode); + setFixed(d.fixedRoleNames); + setClaim(d.mappingBuilder.claimPath); + setRules(d.mappingBuilder.rules); + setRaw(d.rawExpression); +} + export default function PoliciesPage() { const { env } = useEnvContext(); const api = createApiClient({ env }); - const router = useRouter(); const { idpId } = useParams(); const t = useTranslations(); @@ -88,14 +111,39 @@ export default function PoliciesPage() { const [showAddDialog, setShowAddDialog] = useState(false); const [editingPolicy, setEditingPolicy] = useState(null); + const [defaultRoleMappingMode, setDefaultRoleMappingMode] = + useState("fixedRoles"); + const [defaultFixedRoleNames, setDefaultFixedRoleNames] = useState< + string[] + >([]); + const [defaultMappingBuilderClaimPath, setDefaultMappingBuilderClaimPath] = + useState("groups"); + const [defaultMappingBuilderRules, setDefaultMappingBuilderRules] = + useState([createMappingBuilderRule()]); + const [defaultRawRoleExpression, setDefaultRawRoleExpression] = + useState(""); + + const [policyRoleMappingMode, setPolicyRoleMappingMode] = + useState("fixedRoles"); + const [policyFixedRoleNames, setPolicyFixedRoleNames] = useState( + [] + ); + const [policyMappingBuilderClaimPath, setPolicyMappingBuilderClaimPath] = + useState("groups"); + const [policyMappingBuilderRules, setPolicyMappingBuilderRules] = useState< + MappingBuilderRule[] + >([createMappingBuilderRule()]); + const [policyRawRoleExpression, setPolicyRawRoleExpression] = useState(""); + const [policyOrgRoles, setPolicyOrgRoles] = useState< + { roleId: number; name: string }[] + >([]); + const policyFormSchema = z.object({ orgId: z.string().min(1, { message: t("orgRequired") }), - roleMapping: z.string().optional(), orgMapping: z.string().optional() }); const defaultMappingsSchema = z.object({ - defaultRoleMapping: z.string().optional(), defaultOrgMapping: z.string().optional() }); @@ -106,15 +154,15 @@ export default function PoliciesPage() { resolver: zodResolver(policyFormSchema), defaultValues: { orgId: "", - roleMapping: "", orgMapping: "" } }); + const policyFormOrgId = form.watch("orgId"); + const defaultMappingsForm = useForm({ resolver: zodResolver(defaultMappingsSchema), defaultValues: { - defaultRoleMapping: "", defaultOrgMapping: "" } }); @@ -127,9 +175,16 @@ export default function PoliciesPage() { if (res.status === 200) { const data = res.data.data; defaultMappingsForm.reset({ - defaultRoleMapping: data.idp.defaultRoleMapping || "", defaultOrgMapping: data.idp.defaultOrgMapping || "" }); + resetRoleMappingStateFromDetected( + setDefaultRoleMappingMode, + setDefaultFixedRoleNames, + setDefaultMappingBuilderClaimPath, + setDefaultMappingBuilderRules, + setDefaultRawRoleExpression, + data.idp.defaultRoleMapping + ); } } catch (e) { toast({ @@ -184,11 +239,67 @@ export default function PoliciesPage() { load(); }, [idpId]); + useEffect(() => { + if (!showAddDialog) { + return; + } + + const orgId = editingPolicy?.orgId || policyFormOrgId; + if (!orgId) { + setPolicyOrgRoles([]); + return; + } + + let cancelled = false; + (async () => { + const res = await api + .get>(`/org/${orgId}/roles`) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: t("accessRoleErrorFetch"), + description: formatAxiosError( + e, + t("accessRoleErrorFetchDescription") + ) + }); + return null; + }); + if (!cancelled && res?.status === 200) { + setPolicyOrgRoles(res.data.data.roles); + } + })(); + + return () => { + cancelled = true; + }; + }, [showAddDialog, editingPolicy?.orgId, policyFormOrgId, api, t]); + + function resetPolicyDialogRoleMappingState() { + const d = defaultRoleMappingConfig(); + setPolicyRoleMappingMode(d.mode); + setPolicyFixedRoleNames(d.fixedRoleNames); + setPolicyMappingBuilderClaimPath(d.mappingBuilder.claimPath); + setPolicyMappingBuilderRules(d.mappingBuilder.rules); + setPolicyRawRoleExpression(d.rawExpression); + } + const onAddPolicy = async (data: PolicyFormValues) => { + const roleMappingExpression = compileRoleMappingExpression({ + mode: policyRoleMappingMode, + fixedRoleNames: policyFixedRoleNames, + mappingBuilder: { + claimPath: policyMappingBuilderClaimPath, + rules: policyMappingBuilderRules + }, + rawExpression: policyRawRoleExpression + }); + setAddPolicyLoading(true); try { const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, { - roleMapping: data.roleMapping, + roleMapping: roleMappingExpression, orgMapping: data.orgMapping }); if (res.status === 201) { @@ -197,7 +308,7 @@ export default function PoliciesPage() { name: organizations.find((org) => org.orgId === data.orgId) ?.name || "", - roleMapping: data.roleMapping, + roleMapping: roleMappingExpression, orgMapping: data.orgMapping }; setPolicies([...policies, newPolicy]); @@ -207,6 +318,7 @@ export default function PoliciesPage() { }); setShowAddDialog(false); form.reset(); + resetPolicyDialogRoleMappingState(); } } catch (e) { toast({ @@ -222,12 +334,22 @@ export default function PoliciesPage() { const onEditPolicy = async (data: PolicyFormValues) => { if (!editingPolicy) return; + const roleMappingExpression = compileRoleMappingExpression({ + mode: policyRoleMappingMode, + fixedRoleNames: policyFixedRoleNames, + mappingBuilder: { + claimPath: policyMappingBuilderClaimPath, + rules: policyMappingBuilderRules + }, + rawExpression: policyRawRoleExpression + }); + setEditPolicyLoading(true); try { const res = await api.post( `/idp/${idpId}/org/${editingPolicy.orgId}`, { - roleMapping: data.roleMapping, + roleMapping: roleMappingExpression, orgMapping: data.orgMapping } ); @@ -237,7 +359,7 @@ export default function PoliciesPage() { policy.orgId === editingPolicy.orgId ? { ...policy, - roleMapping: data.roleMapping, + roleMapping: roleMappingExpression, orgMapping: data.orgMapping } : policy @@ -250,6 +372,7 @@ export default function PoliciesPage() { setShowAddDialog(false); setEditingPolicy(null); form.reset(); + resetPolicyDialogRoleMappingState(); } } catch (e) { toast({ @@ -287,10 +410,20 @@ export default function PoliciesPage() { }; const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => { + const defaultRoleMappingExpression = compileRoleMappingExpression({ + mode: defaultRoleMappingMode, + fixedRoleNames: defaultFixedRoleNames, + mappingBuilder: { + claimPath: defaultMappingBuilderClaimPath, + rules: defaultMappingBuilderRules + }, + rawExpression: defaultRawRoleExpression + }); + setUpdateDefaultMappingsLoading(true); try { const res = await api.post(`/idp/${idpId}/oidc`, { - defaultRoleMapping: data.defaultRoleMapping, + defaultRoleMapping: defaultRoleMappingExpression, defaultOrgMapping: data.defaultOrgMapping }); if (res.status === 200) { @@ -317,25 +450,36 @@ export default function PoliciesPage() { return ( <> - - - - {t("orgPoliciesAbout")} - - - {/*TODO(vlalx): Validate replacing */} - {t("orgPoliciesAboutDescription")}{" "} - - {t("orgPoliciesAboutDescriptionLink")} - - - - + { + loadOrganizations(); + form.reset({ + orgId: "", + orgMapping: "" + }); + setEditingPolicy(null); + resetPolicyDialogRoleMappingState(); + setShowAddDialog(true); + }} + onEdit={(policy) => { + setEditingPolicy(policy); + form.reset({ + orgId: policy.orgId, + orgMapping: policy.orgMapping || "" + }); + resetRoleMappingStateFromDetected( + setPolicyRoleMappingMode, + setPolicyFixedRoleNames, + setPolicyMappingBuilderClaimPath, + setPolicyMappingBuilderRules, + setPolicyRawRoleExpression, + policy.roleMapping + ); + setShowAddDialog(true); + }} + /> @@ -353,51 +497,58 @@ export default function PoliciesPage() { onUpdateDefaultMappings )} id="policy-default-mappings-form" - className="space-y-4" + className="space-y-6" > - - ( - - - {t("defaultMappingsRole")} - - - - - - {t( - "defaultMappingsRoleDescription" - )} - - - - )} - /> + - ( - - - {t("defaultMappingsOrg")} - - - - - - {t( - "defaultMappingsOrgDescription" - )} - - - - )} - /> - + ( + + + {t("defaultMappingsOrg")} + + + + + + {t( + "defaultMappingsOrgDescription" + )} + + + + )} + /> @@ -411,41 +562,20 @@ export default function PoliciesPage() { - - { - loadOrganizations(); - form.reset({ - orgId: "", - roleMapping: "", - orgMapping: "" - }); - setEditingPolicy(null); - setShowAddDialog(true); - }} - onEdit={(policy) => { - setEditingPolicy(policy); - form.reset({ - orgId: policy.orgId, - roleMapping: policy.roleMapping || "", - orgMapping: policy.orgMapping || "" - }); - setShowAddDialog(true); - }} - /> { setShowAddDialog(val); - setEditingPolicy(null); - form.reset(); + if (!val) { + setEditingPolicy(null); + form.reset(); + resetPolicyDialogRoleMappingState(); + } }} > - + {editingPolicy @@ -456,7 +586,7 @@ export default function PoliciesPage() { {t("orgPolicyConfig")} - + - ( - - - {t("roleMappingPathOptional")} - - - - - - {t( - "defaultMappingsRoleDescription" - )} - - - - )} + ; - interface ProviderTypeOption { - id: "oidc"; - title: string; - description: string; - } - - const providerTypes: ReadonlyArray = [ - { - id: "oidc", - title: "OAuth2/OIDC", - description: t("idpOidcDescription") - } - ]; - const form = useForm({ resolver: zodResolver(createIdpFormSchema), defaultValues: { name: "", - type: "oidc", + type: "oidc" as const, clientId: "", clientSecret: "", authUrl: "", @@ -92,25 +88,46 @@ export default function Page() { namePath: "name", emailPath: "email", scopes: "openid profile email", + tenantId: "", autoProvision: false } }); + const watchedType = form.watch("type"); + const templatesLocked = + !templatesPaid && (watchedType === "google" || watchedType === "azure"); + async function onSubmit(data: CreateIdpFormValues) { + if ( + !templatesPaid && + (data.type === "google" || data.type === "azure") + ) { + return; + } + setCreateLoading(true); try { + let authUrl = data.authUrl; + let tokenUrl = data.tokenUrl; + + if (data.type === "azure" && data.tenantId) { + authUrl = authUrl?.replace("{{TENANT_ID}}", data.tenantId); + tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId); + } + const payload = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, - authUrl: data.authUrl, - tokenUrl: data.tokenUrl, + authUrl: authUrl, + tokenUrl: tokenUrl, identifierPath: data.identifierPath, emailPath: data.emailPath, namePath: data.namePath, autoProvision: data.autoProvision, - scopes: data.scopes + scopes: data.scopes, + variant: data.type }; const res = await api.put("/idp/oidc", payload); @@ -161,332 +178,480 @@ export default function Page() { - - - - ( - - - {t("name")} - - - - - - {t("idpDisplayName")} - - - - )} - /> + {templatesLocked ? ( + + + + ) : null} + { + applyOidcIdpProviderType(form.setValue, next); + }} + /> - - + + + + ( + + + {t("name")} + + + + + + {t("idpDisplayName")} + + + )} - onCheckedChange={(checked) => { - form.setValue( - "autoProvision", - checked - ); - }} /> - - - {t("idpAutoProvisionUsersDescription")} - - - - - {/* */} - {/* */} - {/* */} - {/* {t("idpType")} */} - {/* */} - {/* */} - {/* */} - {/* { */} - {/* form.setValue("type", value as "oidc"); */} - {/* }} */} - {/* cols={3} */} - {/* /> */} - {/* */} + + { + form.setValue( + "autoProvision", + checked + ); + }} + /> + + + + + - {form.watch("type") === "oidc" && ( - + + {watchedType === "google" && ( - {t("idpOidcConfigure")} + {t("idpGoogleConfigurationTitle")} - {t("idpOidcConfigureDescription")} + {t("idpGoogleConfigurationDescription")} - - - ( - - - {t("idpClientId")} - - - - - - {t( - "idpClientIdDescription" - )} - - - + + + + > + ( + + + {t("idpClientId")} + + + + + + {t( + "idpGoogleClientIdDescription" + )} + + + + )} + /> - ( - - - {t("idpClientSecret")} - - - - - - {t( - "idpClientSecretDescription" - )} - - - - )} - /> - - ( - - - {t("idpAuthUrl")} - - - - - - {t( - "idpAuthUrlDescription" - )} - - - - )} - /> - - ( - - - {t("idpTokenUrl")} - - - - - - {t( - "idpTokenUrlDescription" - )} - - - - )} - /> - - - - - - - {t("idpOidcConfigureAlert")} - - - {t("idpOidcConfigureAlertDescription")} - - + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpGoogleClientSecretDescription" + )} + + + + )} + /> + + + + )} + {watchedType === "azure" && ( - {t("idpToken")} + {t("idpAzureConfigurationTitle")} - {t("idpTokenDescription")} + {t("idpAzureConfigurationDescription")} - - - - - - {t("idpJmespathAbout")} - - - {t( - "idpJmespathAboutDescription" - )}{" "} - - {t( - "idpJmespathAboutDescriptionLink" - )}{" "} - - - - - - ( - - - {t("idpJmespathLabel")} - - - - - - {t( - "idpJmespathLabelDescription" - )} - - - + + + + > + ( + + + {t( + "idpTenantIdLabel" + )} + + + + + + {t( + "idpAzureTenantIdDescription" + )} + + + + )} + /> - ( - - - {t( - "idpJmespathEmailPathOptional" - )} - - - - - - {t( - "idpJmespathEmailPathOptionalDescription" - )} - - - - )} - /> + ( + + + {t("idpClientId")} + + + + + + {t( + "idpAzureClientIdDescription2" + )} + + + + )} + /> - ( - - - {t( - "idpJmespathNamePathOptional" - )} - - - - - - {t( - "idpJmespathNamePathOptionalDescription" - )} - - - - )} - /> - - ( - - - {t( - "idpOidcConfigureScopes" - )} - - - - - - {t( - "idpOidcConfigureScopesDescription" - )} - - - - )} - /> - - + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpAzureClientSecretDescription2" + )} + + + + )} + /> + + + - - )} + )} + + {watchedType === "oidc" && ( + + + + + {t("idpOidcConfigure")} + + + {t("idpOidcConfigureDescription")} + + + + + + ( + + + {t("idpClientId")} + + + + + + {t( + "idpClientIdDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpClientSecretDescription" + )} + + + + )} + /> + + ( + + + {t("idpAuthUrl")} + + + + + + {t( + "idpAuthUrlDescription" + )} + + + + )} + /> + + ( + + + {t("idpTokenUrl")} + + + + + + {t( + "idpTokenUrlDescription" + )} + + + + )} + /> + + + + + + + + + {t("idpToken")} + + + {t("idpTokenDescription")} + + + + + + ( + + + {t( + "idpJmespathLabel" + )} + + + + + + {t( + "idpJmespathLabelDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathEmailPathOptional" + )} + + + + + + {t( + "idpJmespathEmailPathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathNamePathOptional" + )} + + + + + + {t( + "idpJmespathNamePathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpOidcConfigureScopes" + )} + + + + + + {t( + "idpOidcConfigureScopesDescription" + )} + + + + )} + /> + + + + + + )} + @@ -501,7 +666,7 @@ export default function Page() { diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 44d85b99e..5f35ee4cd 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -12,6 +12,7 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { Layout } from "@app/components/Layout"; import { adminNavSections } from "../navigation"; import { pullEnv } from "@app/lib/pullEnv"; +import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider"; export const dynamic = "force-dynamic"; @@ -51,9 +52,15 @@ export default async function AdminLayout(props: LayoutProps) { return ( - - {props.children} - + + + {props.children} + + ); } diff --git a/src/components/AutoProvisionConfigWidget.tsx b/src/components/AutoProvisionConfigWidget.tsx index f5979aec3..d4df3f50d 100644 --- a/src/components/AutoProvisionConfigWidget.tsx +++ b/src/components/AutoProvisionConfigWidget.tsx @@ -1,56 +1,51 @@ "use client"; -import { - FormField, - FormItem, - FormLabel, - FormControl, - FormDescription, - FormMessage -} from "@app/components/ui/form"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; +import { FormDescription } from "@app/components/ui/form"; import { SwitchInput } from "@app/components/SwitchInput"; -import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Input } from "@app/components/ui/input"; import { useTranslations } from "next-intl"; -import { Control, FieldValues, Path } from "react-hook-form"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { MappingBuilderRule, RoleMappingMode } from "@app/lib/idpRoleMapping"; +import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; type Role = { roleId: number; name: string; }; -type AutoProvisionConfigWidgetProps = { - control: Control; +type AutoProvisionConfigWidgetProps = { autoProvision: boolean; onAutoProvisionChange: (checked: boolean) => void; - roleMappingMode: "role" | "expression"; - onRoleMappingModeChange: (mode: "role" | "expression") => void; + roleMappingMode: RoleMappingMode; + onRoleMappingModeChange: (mode: RoleMappingMode) => void; roles: Role[]; - roleIdFieldName: Path; - roleMappingFieldName: Path; + fixedRoleNames: string[]; + onFixedRoleNamesChange: (roleNames: string[]) => void; + mappingBuilderClaimPath: string; + onMappingBuilderClaimPathChange: (claimPath: string) => void; + mappingBuilderRules: MappingBuilderRule[]; + onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void; + rawExpression: string; + onRawExpressionChange: (expression: string) => void; }; -export default function AutoProvisionConfigWidget({ - control, +export default function AutoProvisionConfigWidget({ autoProvision, onAutoProvisionChange, roleMappingMode, onRoleMappingModeChange, roles, - roleIdFieldName, - roleMappingFieldName -}: AutoProvisionConfigWidgetProps) { + fixedRoleNames, + onFixedRoleNamesChange, + mappingBuilderClaimPath, + onMappingBuilderClaimPathChange, + mappingBuilderRules, + onMappingBuilderRulesChange, + rawExpression, + onRawExpressionChange +}: AutoProvisionConfigWidgetProps) { const t = useTranslations(); - const { isPaidUser } = usePaidStatus(); return ( @@ -63,114 +58,26 @@ export default function AutoProvisionConfigWidget({ onCheckedChange={onAutoProvisionChange} disabled={!isPaidUser(tierMatrix.autoProvisioning)} /> - - {t("idpAutoProvisionUsersDescription")} - {autoProvision && ( - - - - {t("roleMapping")} - - - {t("roleMappingDescription")} - - - - - - - {t("selectRole")} - - - - - - {t("roleMappingExpression")} - - - - - - {roleMappingMode === "role" ? ( - ( - - - field.onChange(Number(value)) - } - value={field.value?.toString()} - > - - - - - - - {roles.map((role) => ( - - {role.name} - - ))} - - - - {t("selectRoleDescription")} - - - - )} - /> - ) : ( - ( - - - - - - {t("roleMappingExpressionDescription")} - - - - )} - /> - )} - + )} ); diff --git a/src/components/IdpAutoProvisionUsersDescription.tsx b/src/components/IdpAutoProvisionUsersDescription.tsx new file mode 100644 index 000000000..6839ff245 --- /dev/null +++ b/src/components/IdpAutoProvisionUsersDescription.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +const AUTO_PROVISION_DOCS_URL = + "https://docs.pangolin.net/manage/identity-providers/auto-provisioning"; + +type IdpAutoProvisionUsersDescriptionProps = { + className?: string; +}; + +export default function IdpAutoProvisionUsersDescription({ + className +}: IdpAutoProvisionUsersDescriptionProps) { + const t = useTranslations(); + return ( + + {t("idpAutoProvisionUsersDescription")}{" "} + + {t("learnMore")} + + + ); +} diff --git a/src/components/IdpCreateWizard.tsx b/src/components/IdpCreateWizard.tsx index 3fe7e174f..cddad3287 100644 --- a/src/components/IdpCreateWizard.tsx +++ b/src/components/IdpCreateWizard.tsx @@ -27,6 +27,7 @@ import { Checkbox } from "@app/components/ui/checkbox"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon, ExternalLink } from "lucide-react"; import { StrategySelect } from "@app/components/StrategySelect"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { SwitchInput } from "@app/components/SwitchInput"; import { Badge } from "@app/components/ui/badge"; import { useTranslations } from "next-intl"; @@ -163,9 +164,6 @@ export function IdpCreateWizard({ disabled={loading} /> - - {t("idpAutoProvisionUsersDescription")} - diff --git a/src/components/InvitationsTable.tsx b/src/components/InvitationsTable.tsx index 0d2d3e9b6..4fec9e5fc 100644 --- a/src/components/InvitationsTable.tsx +++ b/src/components/InvitationsTable.tsx @@ -1,6 +1,5 @@ "use client"; -import { ColumnDef } from "@tanstack/react-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, @@ -21,13 +20,14 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import moment from "moment"; import { useRouter } from "next/navigation"; +import UserRoleBadges from "@app/components/UserRoleBadges"; export type InvitationRow = { id: string; email: string; expiresAt: string; - role: string; - roleId: number; + roleLabels: string[]; + roleIds: number[]; }; type InvitationsTableProps = { @@ -90,9 +90,13 @@ export default function InvitationsTable({ } }, { - accessorKey: "role", + id: "roles", + accessorFn: (row) => row.roleLabels.join(", "), friendlyName: t("role"), - header: () => {t("role")} + header: () => {t("role")}, + cell: ({ row }) => ( + + ) }, { id: "dots", diff --git a/src/components/OrgRolesTagField.tsx b/src/components/OrgRolesTagField.tsx new file mode 100644 index 000000000..dcd679663 --- /dev/null +++ b/src/components/OrgRolesTagField.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { toast } from "@app/hooks/useToast"; +import { useTranslations } from "next-intl"; +import type { Dispatch, SetStateAction } from "react"; +import type { FieldValues, Path, UseFormReturn } from "react-hook-form"; + +export type RoleTag = { + id: string; + text: string; +}; + +type OrgRolesTagFieldProps = { + form: Pick, "control" | "getValues" | "setValue">; + /** Field in the form that holds Tag[] (role tags). Default: `"roles"`. */ + name?: Path; + label: string; + placeholder: string; + allRoleOptions: Tag[]; + supportsMultipleRolesPerUser: boolean; + showMultiRolePaywallMessage: boolean; + paywallMessage: string; + loading?: boolean; + activeTagIndex: number | null; + setActiveTagIndex: Dispatch>; +}; + +export default function OrgRolesTagField({ + form, + name = "roles" as Path, + label, + placeholder, + allRoleOptions, + supportsMultipleRolesPerUser, + showMultiRolePaywallMessage, + paywallMessage, + loading = false, + activeTagIndex, + setActiveTagIndex +}: OrgRolesTagFieldProps) { + const t = useTranslations(); + + function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) { + const prev = form.getValues(name) as Tag[]; + const nextValue = + typeof updater === "function" ? updater(prev) : updater; + const next = supportsMultipleRolesPerUser + ? nextValue + : nextValue.length > 1 + ? [nextValue[nextValue.length - 1]] + : nextValue; + + if ( + !supportsMultipleRolesPerUser && + next.length === 0 && + prev.length > 0 + ) { + form.setValue(name, [prev[prev.length - 1]] as never, { + shouldDirty: true + }); + return; + } + + if (next.length === 0) { + toast({ + variant: "destructive", + title: t("accessRoleErrorAdd"), + description: t("accessRoleSelectPlease") + }); + return; + } + + form.setValue(name, next as never, { shouldDirty: true }); + } + + return ( + ( + + {label} + + + + {showMultiRolePaywallMessage && ( + {paywallMessage} + )} + + + )} + /> + ); +} diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 1f7c5279d..80a4b9926 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -95,7 +95,8 @@ function getActionsCategories(root: boolean) { [t("actionListRole")]: "listRoles", [t("actionUpdateRole")]: "updateRole", [t("actionListAllowedRoleResources")]: "listRoleResources", - [t("actionAddUserRole")]: "addUserRole" + [t("actionAddUserRole")]: "addUserRole", + [t("actionRemoveUserRole")]: "removeUserRole" }, "Access Token": { [t("actionGenerateAccessToken")]: "generateAccessToken", diff --git a/src/components/RegenerateInvitationForm.tsx b/src/components/RegenerateInvitationForm.tsx index 5d067e7d8..ce261d02e 100644 --- a/src/components/RegenerateInvitationForm.tsx +++ b/src/components/RegenerateInvitationForm.tsx @@ -32,15 +32,15 @@ type RegenerateInvitationFormProps = { invitation: { id: string; email: string; - roleId: number; - role: string; + roleIds: number[]; + roleLabels: string[]; } | null; onRegenerate: (updatedInvitation: { id: string; email: string; expiresAt: string; - role: string; - roleId: number; + roleLabels: string[]; + roleIds: number[]; }) => void; }; @@ -94,7 +94,7 @@ export default function RegenerateInvitationForm({ try { const res = await api.post(`/org/${org.org.orgId}/create-invite`, { email: invitation.email, - roleId: invitation.roleId, + roleIds: invitation.roleIds, validHours, sendEmail, regenerate: true @@ -127,9 +127,11 @@ export default function RegenerateInvitationForm({ onRegenerate({ id: invitation.id, email: invitation.email, - expiresAt: res.data.data.expiresAt, - role: invitation.role, - roleId: invitation.roleId + expiresAt: new Date( + res.data.data.expiresAt + ).toISOString(), + roleLabels: invitation.roleLabels, + roleIds: invitation.roleIds }); } } catch (error: any) { diff --git a/src/components/RoleMappingConfigFields.tsx b/src/components/RoleMappingConfigFields.tsx new file mode 100644 index 000000000..12790d4aa --- /dev/null +++ b/src/components/RoleMappingConfigFields.tsx @@ -0,0 +1,471 @@ +"use client"; + +import { FormLabel, FormDescription } from "@app/components/ui/form"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useState } from "react"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { + createMappingBuilderRule, + MappingBuilderRule, + RoleMappingMode +} from "@app/lib/idpRoleMapping"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { build } from "@server/build"; + +export type RoleMappingRoleOption = { + roleId: number; + name: string; +}; + +export type RoleMappingConfigFieldsProps = { + roleMappingMode: RoleMappingMode; + onRoleMappingModeChange: (mode: RoleMappingMode) => void; + roles: RoleMappingRoleOption[]; + fixedRoleNames: string[]; + onFixedRoleNamesChange: (roleNames: string[]) => void; + mappingBuilderClaimPath: string; + onMappingBuilderClaimPathChange: (claimPath: string) => void; + mappingBuilderRules: MappingBuilderRule[]; + onMappingBuilderRulesChange: (rules: MappingBuilderRule[]) => void; + rawExpression: string; + onRawExpressionChange: (expression: string) => void; + /** Unique prefix for radio `id`/`htmlFor` when multiple instances exist on one page. */ + fieldIdPrefix?: string; + /** When true, show extra hint for global default policies (no org role list). */ + showFreeformRoleNamesHint?: boolean; +}; + +export default function RoleMappingConfigFields({ + roleMappingMode, + onRoleMappingModeChange, + roles, + fixedRoleNames, + onFixedRoleNamesChange, + mappingBuilderClaimPath, + onMappingBuilderClaimPathChange, + mappingBuilderRules, + onMappingBuilderRulesChange, + rawExpression, + onRawExpressionChange, + fieldIdPrefix = "role-mapping", + showFreeformRoleNamesHint = false +}: RoleMappingConfigFieldsProps) { + const t = useTranslations(); + const { env } = useEnvContext(); + const { isPaidUser } = usePaidStatus(); + const [activeFixedRoleTagIndex, setActiveFixedRoleTagIndex] = useState< + number | null + >(null); + + const supportsMultipleRolesPerUser = isPaidUser(tierMatrix.fullRbac); + const showSingleRoleDisclaimer = + !env.flags.disableEnterpriseFeatures && + !isPaidUser(tierMatrix.fullRbac); + + const restrictToOrgRoles = roles.length > 0; + + const roleOptions = useMemo( + () => + roles.map((role) => ({ + id: role.name, + text: role.name + })), + [roles] + ); + + useEffect(() => { + if ( + !supportsMultipleRolesPerUser && + mappingBuilderRules.length > 1 + ) { + onMappingBuilderRulesChange([mappingBuilderRules[0]]); + } + }, [ + supportsMultipleRolesPerUser, + mappingBuilderRules, + onMappingBuilderRulesChange + ]); + + useEffect(() => { + if (!supportsMultipleRolesPerUser && fixedRoleNames.length > 1) { + onFixedRoleNamesChange([fixedRoleNames[0]]); + } + }, [ + supportsMultipleRolesPerUser, + fixedRoleNames, + onFixedRoleNamesChange + ]); + + const fixedRadioId = `${fieldIdPrefix}-fixed-roles-mode`; + const builderRadioId = `${fieldIdPrefix}-mapping-builder-mode`; + const rawRadioId = `${fieldIdPrefix}-raw-expression-mode`; + + const mappingBuilderShowsRemoveColumn = + supportsMultipleRolesPerUser || mappingBuilderRules.length > 1; + + /** Same template on header + rows so 1fr/1.75fr columns line up (auto third col differs per row otherwise). */ + const mappingRulesGridClass = mappingBuilderShowsRemoveColumn + ? "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)_6rem] md:gap-x-3" + : "md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.75fr)] md:gap-x-3"; + + return ( + + + {t("roleMapping")} + + {t("roleMappingDescription")} + + + + + + + {t("roleMappingModeFixedRoles")} + + + + + + {t("roleMappingModeMappingBuilder")} + + + + + + {t("roleMappingModeRawExpression")} + + + + {showSingleRoleDisclaimer && ( + + {build === "saas" + ? t("singleRolePerUserPlanNotice") + : t("singleRolePerUserEditionNotice")} + + )} + + + {roleMappingMode === "fixedRoles" && ( + + ({ + id: name, + text: name + }))} + setTags={(nextTags) => { + const prevTags = fixedRoleNames.map((name) => ({ + id: name, + text: name + })); + const next = + typeof nextTags === "function" + ? nextTags(prevTags) + : nextTags; + + let names = [ + ...new Set(next.map((tag) => tag.text)) + ]; + + if (!supportsMultipleRolesPerUser) { + if ( + names.length === 0 && + fixedRoleNames.length > 0 + ) { + onFixedRoleNamesChange([ + fixedRoleNames[ + fixedRoleNames.length - 1 + ]! + ]); + return; + } + if (names.length > 1) { + names = [names[names.length - 1]!]; + } + } + + onFixedRoleNamesChange(names); + }} + activeTagIndex={activeFixedRoleTagIndex} + setActiveTagIndex={setActiveFixedRoleTagIndex} + placeholder={ + restrictToOrgRoles + ? t("roleMappingFixedRolesPlaceholderSelect") + : t("roleMappingFixedRolesPlaceholderFreeform") + } + enableAutocomplete={restrictToOrgRoles} + autocompleteOptions={roleOptions} + restrictTagsToAutocompleteOptions={restrictToOrgRoles} + allowDuplicates={false} + sortTags={true} + size="sm" + /> + + {showFreeformRoleNamesHint + ? t("roleMappingFixedRolesDescriptionDefaultPolicy") + : t("roleMappingFixedRolesDescriptionSameForAll")} + + + )} + + {roleMappingMode === "mappingBuilder" && ( + + + {t("roleMappingClaimPath")} + + onMappingBuilderClaimPathChange(e.target.value) + } + placeholder={t("roleMappingClaimPathPlaceholder")} + /> + + {t("roleMappingClaimPathDescription")} + + + + + + + {t("roleMappingMatchValue")} + + + {t("roleMappingAssignRoles")} + + {mappingBuilderShowsRemoveColumn ? ( + + ) : null} + + + {mappingBuilderRules.map((rule, index) => ( + { + const nextRules = mappingBuilderRules.map( + (row, i) => + i === index ? nextRule : row + ); + onMappingBuilderRulesChange(nextRules); + }} + onRemove={() => { + const nextRules = + mappingBuilderRules.filter( + (_, i) => i !== index + ); + onMappingBuilderRulesChange( + nextRules.length + ? nextRules + : [createMappingBuilderRule()] + ); + }} + /> + ))} + + + {supportsMultipleRolesPerUser ? ( + { + onMappingBuilderRulesChange([ + ...mappingBuilderRules, + createMappingBuilderRule() + ]); + }} + > + {t("roleMappingAddMappingRule")} + + ) : null} + + )} + + {roleMappingMode === "rawExpression" && ( + + onRawExpressionChange(e.target.value)} + placeholder={t("roleMappingExpressionPlaceholder")} + /> + + {supportsMultipleRolesPerUser + ? t("roleMappingRawExpressionResultDescription") + : t( + "roleMappingRawExpressionResultDescriptionSingleRole" + )} + + + )} + + ); +} + +function BuilderRuleRow({ + rule, + roleOptions, + restrictToOrgRoles, + showFreeformRoleNamesHint, + fieldIdPrefix, + mappingRulesGridClass, + supportsMultipleRolesPerUser, + showRemoveButton, + onChange, + onRemove +}: { + rule: MappingBuilderRule; + roleOptions: Tag[]; + restrictToOrgRoles: boolean; + showFreeformRoleNamesHint: boolean; + fieldIdPrefix: string; + mappingRulesGridClass: string; + supportsMultipleRolesPerUser: boolean; + showRemoveButton: boolean; + onChange: (rule: MappingBuilderRule) => void; + onRemove: () => void; +}) { + const t = useTranslations(); + const [activeTagIndex, setActiveTagIndex] = useState(null); + + return ( + + + + {t("roleMappingMatchValue")} + + + onChange({ + ...rule, + matchValue: e.target.value + }) + } + placeholder={t("roleMappingMatchValuePlaceholder")} + /> + + + + {t("roleMappingAssignRoles")} + + + ({ + id: name, + text: name + }))} + setTags={(nextTags) => { + const prevRoleTags = rule.roleNames.map( + (name) => ({ + id: name, + text: name + }) + ); + const next = + typeof nextTags === "function" + ? nextTags(prevRoleTags) + : nextTags; + + let names = [ + ...new Set(next.map((tag) => tag.text)) + ]; + + if (!supportsMultipleRolesPerUser) { + if ( + names.length === 0 && + rule.roleNames.length > 0 + ) { + onChange({ + ...rule, + roleNames: [ + rule.roleNames[ + rule.roleNames.length - 1 + ]! + ] + }); + return; + } + if (names.length > 1) { + names = [names[names.length - 1]!]; + } + } + + onChange({ + ...rule, + roleNames: names + }); + }} + activeTagIndex={activeTagIndex} + setActiveTagIndex={setActiveTagIndex} + placeholder={ + restrictToOrgRoles + ? t("roleMappingAssignRoles") + : t("roleMappingAssignRolesPlaceholderFreeform") + } + enableAutocomplete={restrictToOrgRoles} + autocompleteOptions={roleOptions} + restrictTagsToAutocompleteOptions={restrictToOrgRoles} + allowDuplicates={false} + sortTags={true} + size="sm" + styleClasses={{ + inlineTagsContainer: "min-w-0 max-w-full" + }} + /> + + {showFreeformRoleNamesHint && ( + + {t("roleMappingBuilderFreeformRowHint")} + + )} + + {showRemoveButton ? ( + + + {t("roleMappingRemoveRule")} + + + ) : null} + + ); +} diff --git a/src/components/UserRoleBadges.tsx b/src/components/UserRoleBadges.tsx new file mode 100644 index 000000000..4888aa107 --- /dev/null +++ b/src/components/UserRoleBadges.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useState } from "react"; +import { Badge, badgeVariants } from "@app/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; + +const MAX_ROLE_BADGES = 3; + +export default function UserRoleBadges({ + roleLabels +}: { + roleLabels: string[]; +}) { + const visible = roleLabels.slice(0, MAX_ROLE_BADGES); + const overflow = roleLabels.slice(MAX_ROLE_BADGES); + + return ( + + {visible.map((label, i) => ( + + {label} + + ))} + {overflow.length > 0 && ( + + )} + + ); +} + +function OverflowRolesPopover({ labels }: { labels: string[] }) { + const [open, setOpen] = useState(false); + + return ( + + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + +{labels.length} + + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + + {labels.map((label, i) => ( + {label} + ))} + + + + ); +} diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 9b1dfee68..3e2d4e578 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -24,6 +24,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "./IdpTypeBadge"; +import UserRoleBadges from "./UserRoleBadges"; export type UserRow = { id: string; @@ -36,7 +37,7 @@ export type UserRow = { type: string; idpVariant: string | null; status: string; - role: string; + roleLabels: string[]; isOwner: boolean; }; @@ -124,7 +125,8 @@ export default function UsersTable({ users: u }: UsersTableProps) { } }, { - accessorKey: "role", + id: "role", + accessorFn: (row) => row.roleLabels.join(", "), friendlyName: t("role"), header: ({ column }) => { return ( @@ -140,13 +142,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { ); }, cell: ({ row }) => { - const userRow = row.original; - - return ( - - {userRow.role} - - ); + return ; } }, { diff --git a/src/components/idp/OidcIdpProviderTypeSelect.tsx b/src/components/idp/OidcIdpProviderTypeSelect.tsx new file mode 100644 index 000000000..4665d9c0d --- /dev/null +++ b/src/components/idp/OidcIdpProviderTypeSelect.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { + StrategySelect, + type StrategyOption +} from "@app/components/StrategySelect"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import type { IdpOidcProviderType } from "@app/lib/idp/oidcIdpProviderDefaults"; +import { useTranslations } from "next-intl"; +import Image from "next/image"; +import { useEffect, useMemo } from "react"; + +type Props = { + value: IdpOidcProviderType; + onTypeChange: (type: IdpOidcProviderType) => void; +}; + +export function OidcIdpProviderTypeSelect({ value, onTypeChange }: Props) { + const t = useTranslations(); + const { env } = useEnvContext(); + const hideTemplates = env.flags.disableEnterpriseFeatures; + + useEffect(() => { + if (hideTemplates && (value === "google" || value === "azure")) { + onTypeChange("oidc"); + } + }, [hideTemplates, value, onTypeChange]); + + const options: ReadonlyArray> = + useMemo(() => { + const base: StrategyOption[] = [ + { + id: "oidc", + title: "OAuth2/OIDC", + description: t("idpOidcDescription") + } + ]; + if (hideTemplates) { + return base; + } + return [ + ...base, + { + id: "google", + title: t("idpGoogleTitle"), + description: t("idpGoogleDescription"), + icon: ( + + ) + }, + { + id: "azure", + title: t("idpAzureTitle"), + description: t("idpAzureDescription"), + icon: ( + + ) + } + ]; + }, [hideTemplates, t]); + + return ( + + + {t("idpType")} + + + + ); +} diff --git a/src/components/tags/autocomplete.tsx b/src/components/tags/autocomplete.tsx index ee865eb61..916e7aeed 100644 --- a/src/components/tags/autocomplete.tsx +++ b/src/components/tags/autocomplete.tsx @@ -1,10 +1,23 @@ -import React, { useCallback, useEffect, useRef, useState } from "react"; -// import { Command, CommandList, CommandItem, CommandGroup, CommandEmpty } from '../ui/command'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "../ui/command"; +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger +} from "../ui/popover"; import { Button } from "../ui/button"; import { cn } from "@app/lib/cn"; import { useTranslations } from "next-intl"; +import { Check } from "lucide-react"; type AutocompleteProps = { tags: TagType[]; @@ -20,6 +33,8 @@ type AutocompleteProps = { inlineTags?: boolean; classStyleProps: TagInputStyleClassesProps["autoComplete"]; usePortal?: boolean; + /** Narrows the dropdown list from the main field (cmdk search filters further). */ + filterQuery?: string; }; export const Autocomplete: React.FC = ({ @@ -35,10 +50,10 @@ export const Autocomplete: React.FC = ({ inlineTags, children, classStyleProps, - usePortal + usePortal, + filterQuery = "" }) => { const triggerContainerRef = useRef(null); - const triggerRef = useRef(null); const inputRef = useRef(null); const popoverContentRef = useRef(null); const t = useTranslations(); @@ -46,17 +61,21 @@ export const Autocomplete: React.FC = ({ const [popoverWidth, setPopoverWidth] = useState(0); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [inputFocused, setInputFocused] = useState(false); - const [popooverContentTop, setPopoverContentTop] = useState(0); - const [selectedIndex, setSelectedIndex] = useState(-1); + const [commandResetKey, setCommandResetKey] = useState(0); - // Dynamically calculate the top position for the popover content - useEffect(() => { - if (!triggerContainerRef.current || !triggerRef.current) return; - setPopoverContentTop( - triggerContainerRef.current?.getBoundingClientRect().bottom - - triggerRef.current?.getBoundingClientRect().bottom + const visibleOptions = useMemo(() => { + const q = filterQuery.trim().toLowerCase(); + if (!q) return autocompleteOptions; + return autocompleteOptions.filter((option) => + option.text.toLowerCase().includes(q) ); - }, [tags]); + }, [autocompleteOptions, filterQuery]); + + useEffect(() => { + if (isPopoverOpen) { + setCommandResetKey((k) => k + 1); + } + }, [isPopoverOpen]); // Close the popover when clicking outside of it useEffect(() => { @@ -135,36 +154,6 @@ export const Autocomplete: React.FC = ({ if (userOnBlur) userOnBlur(event); }; - const handleKeyDown = (event: React.KeyboardEvent) => { - if (!isPopoverOpen) return; - - switch (event.key) { - case "ArrowUp": - event.preventDefault(); - setSelectedIndex((prevIndex) => - prevIndex <= 0 - ? autocompleteOptions.length - 1 - : prevIndex - 1 - ); - break; - case "ArrowDown": - event.preventDefault(); - setSelectedIndex((prevIndex) => - prevIndex === autocompleteOptions.length - 1 - ? 0 - : prevIndex + 1 - ); - break; - case "Enter": - event.preventDefault(); - if (selectedIndex !== -1) { - toggleTag(autocompleteOptions[selectedIndex]); - setSelectedIndex(-1); - } - break; - } - }; - const toggleTag = (option: TagType) => { // Check if the tag already exists in the array const index = tags.findIndex((tag) => tag.text === option.text); @@ -197,18 +186,25 @@ export const Autocomplete: React.FC = ({ } } } - setSelectedIndex(-1); }; - const childrenWithProps = React.cloneElement( - children as React.ReactElement, - { - onKeyDown: handleKeyDown, - onFocus: handleInputFocus, - onBlur: handleInputBlur, - ref: inputRef + const child = children as React.ReactElement< + React.InputHTMLAttributes & { + ref?: React.Ref; } - ); + >; + const userOnKeyDown = child.props.onKeyDown; + + const childrenWithProps = React.cloneElement(child, { + onKeyDown: userOnKeyDown, + onFocus: handleInputFocus, + onBlur: handleInputBlur, + ref: inputRef + } as Partial< + React.InputHTMLAttributes & { + ref?: React.Ref; + } + >); return ( = ({ onOpenChange={handleOpenChange} modal={usePortal} > - - {childrenWithProps} - - { - setIsPopoverOpen(!isPopoverOpen); - }} - > - + + {childrenWithProps} + + { + setIsPopoverOpen(!isPopoverOpen); + }} > - - - - - + + + + + + + - - {autocompleteOptions.length > 0 ? ( - + + {t("noResults")} + - - Suggestions - - - {autocompleteOptions.map((option, index) => { - const isSelected = index === selectedIndex; + {visibleOptions.map((option) => { + const isChosen = tags.some( + (tag) => tag.text === option.text + ); return ( - toggleTag(option)} + value={`${option.text} ${option.id}`} + onSelect={() => toggleTag(option)} + className={classStyleProps?.commandItem} > - - {option.text} - {tags.some( - (tag) => - tag.text === option.text - ) && ( - - - + - + /> + {option.text} + ); })} - - ) : ( - - {t("noResults")} - - )} - + + + diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index 269967ccb..e8cfa370a 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useMemo } from "react"; +import React from "react"; import { Input } from "../ui/input"; import { Button } from "../ui/button"; import { type VariantProps } from "class-variance-authority"; @@ -434,14 +434,6 @@ const TagInput = React.forwardRef( // const filteredAutocompleteOptions = autocompleteFilter // ? autocompleteOptions?.filter((option) => autocompleteFilter(option.text)) // : autocompleteOptions; - const filteredAutocompleteOptions = useMemo(() => { - return (autocompleteOptions || []).filter((option) => - option.text - .toLowerCase() - .includes(inputValue ? inputValue.toLowerCase() : "") - ); - }, [inputValue, autocompleteOptions]); - const displayedTags = sortTags ? [...tags].sort() : tags; const truncatedTags = truncate @@ -571,9 +563,9 @@ const TagInput = React.forwardRef( tags={tags} setTags={setTags} setInputValue={setInputValue} - autocompleteOptions={ - filteredAutocompleteOptions as Tag[] - } + autocompleteOptions={(autocompleteOptions || + []) as Tag[]} + filterQuery={inputValue} setTagCount={setTagCount} maxTags={maxTags} onTagAdd={onTagAdd} diff --git a/src/components/tags/tag-popover.tsx b/src/components/tags/tag-popover.tsx index 533619a7c..93f5e2c04 100644 --- a/src/components/tags/tag-popover.tsx +++ b/src/components/tags/tag-popover.tsx @@ -1,5 +1,10 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger +} from "../ui/popover"; import { TagInputStyleClassesProps, type Tag as TagType } from "./tag-input"; import { TagList, TagListProps } from "./tag-list"; import { Button } from "../ui/button"; @@ -33,33 +38,27 @@ export const TagPopover: React.FC = ({ ...tagProps }) => { const triggerContainerRef = useRef(null); - const triggerRef = useRef(null); const popoverContentRef = useRef(null); const inputRef = useRef(null); const [popoverWidth, setPopoverWidth] = useState(0); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [inputFocused, setInputFocused] = useState(false); - const [sideOffset, setSideOffset] = useState(0); const t = useTranslations(); useEffect(() => { const handleResize = () => { - if (triggerContainerRef.current && triggerRef.current) { + if (triggerContainerRef.current) { setPopoverWidth(triggerContainerRef.current.offsetWidth); - setSideOffset( - triggerContainerRef.current.offsetWidth - - triggerRef?.current?.offsetWidth - ); } }; - handleResize(); // Call on mount and layout changes + handleResize(); - window.addEventListener("resize", handleResize); // Adjust on window resize + window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); - }, [triggerContainerRef, triggerRef]); + }, []); // Close the popover when clicking outside of it useEffect(() => { @@ -135,52 +134,54 @@ export const TagPopover: React.FC = ({ onOpenChange={handleOpenChange} modal={usePortal} > - - {React.cloneElement(children as React.ReactElement, { - onFocus: handleInputFocus, - onBlur: handleInputBlur, - ref: inputRef - })} - - setIsPopoverOpen(!isPopoverOpen)} - > - + + {React.cloneElement(children as React.ReactElement, { + onFocus: handleInputFocus, + onBlur: handleInputBlur, + ref: inputRef + })} + + setIsPopoverOpen(!isPopoverOpen)} > - - - - - + + + + + + + diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 4d38e0fd4..2b474f45b 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@app/lib/cn"; const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0", + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0", { variants: { variant: { diff --git a/src/lib/idp/oidcIdpProviderDefaults.ts b/src/lib/idp/oidcIdpProviderDefaults.ts new file mode 100644 index 000000000..3608c6882 --- /dev/null +++ b/src/lib/idp/oidcIdpProviderDefaults.ts @@ -0,0 +1,46 @@ +import type { FieldValues, UseFormSetValue } from "react-hook-form"; + +export type IdpOidcProviderType = "oidc" | "google" | "azure"; + +export function applyOidcIdpProviderType( + setValue: UseFormSetValue, + provider: IdpOidcProviderType +): void { + setValue("type" as never, provider as never); + + if (provider === "google") { + setValue( + "authUrl" as never, + "https://accounts.google.com/o/oauth2/v2/auth" as never + ); + setValue( + "tokenUrl" as never, + "https://oauth2.googleapis.com/token" as never + ); + setValue("identifierPath" as never, "email" as never); + setValue("emailPath" as never, "email" as never); + setValue("namePath" as never, "name" as never); + setValue("scopes" as never, "openid profile email" as never); + } else if (provider === "azure") { + setValue( + "authUrl" as never, + "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize" as never + ); + setValue( + "tokenUrl" as never, + "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token" as never + ); + setValue("identifierPath" as never, "email" as never); + setValue("emailPath" as never, "email" as never); + setValue("namePath" as never, "name" as never); + setValue("scopes" as never, "openid profile email" as never); + setValue("tenantId" as never, "" as never); + } else { + setValue("authUrl" as never, "" as never); + setValue("tokenUrl" as never, "" as never); + setValue("identifierPath" as never, "sub" as never); + setValue("namePath" as never, "name" as never); + setValue("emailPath" as never, "email" as never); + setValue("scopes" as never, "openid profile email" as never); + } +} diff --git a/src/lib/idpRoleMapping.ts b/src/lib/idpRoleMapping.ts new file mode 100644 index 000000000..2b336b3dd --- /dev/null +++ b/src/lib/idpRoleMapping.ts @@ -0,0 +1,266 @@ +export type RoleMappingMode = "fixedRoles" | "mappingBuilder" | "rawExpression"; + +export type MappingBuilderRule = { + /** Stable React list key; not used when compiling JMESPath. */ + id?: string; + matchValue: string; + roleNames: string[]; +}; + +function newMappingBuilderRuleId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `rule-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + +export function createMappingBuilderRule(): MappingBuilderRule { + return { + id: newMappingBuilderRuleId(), + matchValue: "", + roleNames: [] + }; +} + +/** Ensures every rule has a stable id (e.g. after loading from the API). */ +export function ensureMappingBuilderRuleIds( + rules: MappingBuilderRule[] +): MappingBuilderRule[] { + return rules.map((rule) => + rule.id ? rule : { ...rule, id: newMappingBuilderRuleId() } + ); +} + +export type MappingBuilderConfig = { + claimPath: string; + rules: MappingBuilderRule[]; +}; + +export type RoleMappingConfig = { + mode: RoleMappingMode; + fixedRoleNames: string[]; + mappingBuilder: MappingBuilderConfig; + rawExpression: string; +}; + +const SINGLE_QUOTED_ROLE_REGEX = /^'([^']+)'$/; +const QUOTED_ROLE_ARRAY_REGEX = /^\[(.*)\]$/; + +/** Stored role mappings created by the mapping builder are prefixed so the UI can restore the builder. */ +export const PANGOLIN_ROLE_MAP_BUILDER_PREFIX = "__PANGOLIN_ROLE_MAP_BUILDER_V1__"; + +const BUILDER_METADATA_SEPARATOR = "\n---\n"; + +export type UnwrappedRoleMapping = { + /** Expression passed to JMESPath (no builder wrapper). */ + evaluationExpression: string; + /** Present when the stored value was saved from the mapping builder. */ + builderState: { claimPath: string; rules: MappingBuilderRule[] } | null; +}; + +/** + * Split stored DB value into evaluation expression and optional builder metadata. + * Legacy values (no prefix) are returned as-is for evaluation. + */ +export function unwrapRoleMapping( + stored: string | null | undefined +): UnwrappedRoleMapping { + const trimmed = stored?.trim() ?? ""; + if (!trimmed.startsWith(PANGOLIN_ROLE_MAP_BUILDER_PREFIX)) { + return { + evaluationExpression: trimmed, + builderState: null + }; + } + + let rest = trimmed.slice(PANGOLIN_ROLE_MAP_BUILDER_PREFIX.length); + if (rest.startsWith("\n")) { + rest = rest.slice(1); + } + + const sepIdx = rest.indexOf(BUILDER_METADATA_SEPARATOR); + if (sepIdx === -1) { + return { + evaluationExpression: trimmed, + builderState: null + }; + } + + const jsonPart = rest.slice(0, sepIdx).trim(); + const inner = rest.slice(sepIdx + BUILDER_METADATA_SEPARATOR.length).trim(); + + try { + const meta = JSON.parse(jsonPart) as { + claimPath?: unknown; + rules?: unknown; + }; + if ( + typeof meta.claimPath === "string" && + Array.isArray(meta.rules) + ) { + const rules: MappingBuilderRule[] = meta.rules.map( + (r: unknown) => { + const row = r as { + matchValue?: unknown; + roleNames?: unknown; + }; + return { + matchValue: + typeof row.matchValue === "string" + ? row.matchValue + : "", + roleNames: Array.isArray(row.roleNames) + ? row.roleNames.filter( + (n): n is string => typeof n === "string" + ) + : [] + }; + } + ); + return { + evaluationExpression: inner, + builderState: { + claimPath: meta.claimPath, + rules: ensureMappingBuilderRuleIds(rules) + } + }; + } + } catch { + /* fall through */ + } + + return { + evaluationExpression: inner.length ? inner : trimmed, + builderState: null + }; +} + +function escapeSingleQuotes(value: string): string { + return value.replace(/'/g, "\\'"); +} + +export function compileRoleMappingExpression(config: RoleMappingConfig): string { + if (config.mode === "rawExpression") { + return config.rawExpression.trim(); + } + + if (config.mode === "fixedRoles") { + const roleNames = dedupeNonEmpty(config.fixedRoleNames); + if (!roleNames.length) { + return ""; + } + + if (roleNames.length === 1) { + return `'${escapeSingleQuotes(roleNames[0])}'`; + } + + return `[${roleNames.map((name) => `'${escapeSingleQuotes(name)}'`).join(", ")}]`; + } + + const claimPath = config.mappingBuilder.claimPath.trim(); + const rules = config.mappingBuilder.rules + .map((rule) => ({ + matchValue: rule.matchValue.trim(), + roleNames: dedupeNonEmpty(rule.roleNames) + })) + .filter((rule) => Boolean(rule.matchValue) && rule.roleNames.length > 0); + + if (!claimPath || !rules.length) { + return ""; + } + + const compiledRules = rules.map((rule) => { + const mappedRoles = `[${rule.roleNames + .map((name) => `'${escapeSingleQuotes(name)}'`) + .join(", ")}]`; + return `contains(${claimPath}, '${escapeSingleQuotes(rule.matchValue)}') && ${mappedRoles} || []`; + }); + + const inner = `[${compiledRules.join(", ")}][]`; + const metadata = { + claimPath, + rules: rules.map((r) => ({ + matchValue: r.matchValue, + roleNames: r.roleNames + })) + }; + + return `${PANGOLIN_ROLE_MAP_BUILDER_PREFIX}\n${JSON.stringify(metadata)}${BUILDER_METADATA_SEPARATOR}${inner}`; +} + +export function detectRoleMappingConfig( + expression: string | null | undefined +): RoleMappingConfig { + const stored = expression?.trim() || ""; + + if (!stored) { + return defaultRoleMappingConfig(); + } + + const { evaluationExpression, builderState } = unwrapRoleMapping(stored); + + if (builderState) { + return { + mode: "mappingBuilder", + fixedRoleNames: [], + mappingBuilder: { + claimPath: builderState.claimPath, + rules: builderState.rules + }, + rawExpression: evaluationExpression + }; + } + + const tail = evaluationExpression.trim(); + + const singleMatch = tail.match(SINGLE_QUOTED_ROLE_REGEX); + if (singleMatch?.[1]) { + return { + mode: "fixedRoles", + fixedRoleNames: [singleMatch[1]], + mappingBuilder: defaultRoleMappingConfig().mappingBuilder, + rawExpression: tail + }; + } + + const arrayMatch = tail.match(QUOTED_ROLE_ARRAY_REGEX); + if (arrayMatch?.[1]) { + const roleNames = arrayMatch[1] + .split(",") + .map((entry) => entry.trim()) + .map((entry) => entry.match(SINGLE_QUOTED_ROLE_REGEX)?.[1] || "") + .filter(Boolean); + + if (roleNames.length > 0) { + return { + mode: "fixedRoles", + fixedRoleNames: roleNames, + mappingBuilder: defaultRoleMappingConfig().mappingBuilder, + rawExpression: tail + }; + } + } + + return { + mode: "rawExpression", + fixedRoleNames: [], + mappingBuilder: defaultRoleMappingConfig().mappingBuilder, + rawExpression: tail + }; +} + +export function defaultRoleMappingConfig(): RoleMappingConfig { + return { + mode: "fixedRoles", + fixedRoleNames: [], + mappingBuilder: { + claimPath: "groups", + rules: [createMappingBuilderRule()] + }, + rawExpression: "" + }; +} + +function dedupeNonEmpty(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +}
+ {t("roleMappingBuilderFreeformRowHint")} +