Merge branch 'multi-role' into dev

This commit is contained in:
Owen
2026-03-29 13:55:23 -07:00
126 changed files with 5337 additions and 2014 deletions

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Изтрийте потребител", "actionRemoveUser": "Изтрийте потребител",
"actionListUsers": "Изброяване на потребители", "actionListUsers": "Изброяване на потребители",
"actionAddUserRole": "Добавяне на роля на потребител", "actionAddUserRole": "Добавяне на роля на потребител",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Генериране на токен за достъп", "actionGenerateAccessToken": "Генериране на токен за достъп",
"actionDeleteAccessToken": "Изтриване на токен за достъп", "actionDeleteAccessToken": "Изтриване на токен за достъп",
"actionListAccessTokens": "Изброяване на токени за достъп", "actionListAccessTokens": "Изброяване на токени за достъп",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Odstranit uživatele", "actionRemoveUser": "Odstranit uživatele",
"actionListUsers": "Seznam uživatelů", "actionListUsers": "Seznam uživatelů",
"actionAddUserRole": "Přidat uživatelskou roli", "actionAddUserRole": "Přidat uživatelskou roli",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generovat přístupový token", "actionGenerateAccessToken": "Generovat přístupový token",
"actionDeleteAccessToken": "Odstranit přístupový token", "actionDeleteAccessToken": "Odstranit přístupový token",
"actionListAccessTokens": "Seznam přístupových tokenů", "actionListAccessTokens": "Seznam přístupových tokenů",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Benutzer entfernen", "actionRemoveUser": "Benutzer entfernen",
"actionListUsers": "Benutzer auflisten", "actionListUsers": "Benutzer auflisten",
"actionAddUserRole": "Benutzerrolle hinzufügen", "actionAddUserRole": "Benutzerrolle hinzufügen",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Zugriffstoken generieren", "actionGenerateAccessToken": "Zugriffstoken generieren",
"actionDeleteAccessToken": "Zugriffstoken löschen", "actionDeleteAccessToken": "Zugriffstoken löschen",
"actionListAccessTokens": "Zugriffstoken auflisten", "actionListAccessTokens": "Zugriffstoken auflisten",

View File

@@ -514,9 +514,12 @@
"userSaved": "User saved", "userSaved": "User saved",
"userSavedDescription": "The user has been updated.", "userSavedDescription": "The user has been updated.",
"autoProvisioned": "Auto Provisioned", "autoProvisioned": "Auto Provisioned",
"autoProvisionSettings": "Auto Provision Settings",
"autoProvisionedDescription": "Allow this user to be automatically managed by identity provider", "autoProvisionedDescription": "Allow this user to be automatically managed by identity provider",
"accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsDescription": "Manage what this user can access and do in the organization",
"accessControlsSubmit": "Save Access Controls", "accessControlsSubmit": "Save Access Controls",
"singleRolePerUserPlanNotice": "Your plan only supports one role per user.",
"singleRolePerUserEditionNotice": "This edition only supports one role per user.",
"roles": "Roles", "roles": "Roles",
"accessUsersRoles": "Manage Users & Roles", "accessUsersRoles": "Manage Users & Roles",
"accessUsersRolesDescription": "Invite users and add them to roles to manage access to the organization", "accessUsersRolesDescription": "Invite users and add them to roles to manage access to the organization",
@@ -892,7 +895,7 @@
"defaultMappingsRole": "Default Role Mapping", "defaultMappingsRole": "Default Role Mapping",
"defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.",
"defaultMappingsOrg": "Default Organization Mapping", "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", "defaultMappingsSubmit": "Save Default Mappings",
"orgPoliciesEdit": "Edit Organization Policy", "orgPoliciesEdit": "Edit Organization Policy",
"org": "Organization", "org": "Organization",
@@ -1045,7 +1048,6 @@
"pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.", "pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.",
"overview": "Overview", "overview": "Overview",
"home": "Home", "home": "Home",
"accessControl": "Access Control",
"settings": "Settings", "settings": "Settings",
"usersAll": "All Users", "usersAll": "All Users",
"license": "License", "license": "License",
@@ -1155,6 +1157,7 @@
"actionRemoveUser": "Remove User", "actionRemoveUser": "Remove User",
"actionListUsers": "List Users", "actionListUsers": "List Users",
"actionAddUserRole": "Add User Role", "actionAddUserRole": "Add User Role",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generate Access Token", "actionGenerateAccessToken": "Generate Access Token",
"actionDeleteAccessToken": "Delete Access Token", "actionDeleteAccessToken": "Delete Access Token",
"actionListAccessTokens": "List Access Tokens", "actionListAccessTokens": "List Access Tokens",
@@ -1944,6 +1947,25 @@
"invalidValue": "Invalid value", "invalidValue": "Invalid value",
"idpTypeLabel": "Identity Provider Type", "idpTypeLabel": "Identity Provider Type",
"roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'", "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", "idpGoogleConfiguration": "Google Configuration",
"idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials", "idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials",
"idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientIdDescription": "Google OAuth2 Client ID",
@@ -2516,9 +2538,9 @@
"remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?", "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.", "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", "agent": "Agent",
"personalUseOnly": "Personal Use Only", "personalUseOnly": "Personal Use Only",
"loginPageLicenseWatermark": "This instance is licensed for personal use only.", "loginPageLicenseWatermark": "This instance is licensed for personal use only.",
"instanceIsUnlicensed": "This instance is unlicensed.", "instanceIsUnlicensed": "This instance is unlicensed.",
"portRestrictions": "Port Restrictions", "portRestrictions": "Port Restrictions",
"allPorts": "All", "allPorts": "All",
"custom": "Custom", "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.", "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", "forced": "Forced",
"forcedModeDescription": "Always show the maintenance page regardless of backend health. Use this for planned maintenance when you want to prevent all access.", "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.", "forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.",
"pageTitle": "Page Title", "pageTitle": "Page Title",
"pageTitleDescription": "The main heading displayed on the maintenance page", "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.", "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", "approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
"approvalsEmptyStateButtonText": "Manage Roles", "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 <policiesTabLink>Auto Provision Settings</policiesTabLink> tab."
} }

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Eliminar usuario", "actionRemoveUser": "Eliminar usuario",
"actionListUsers": "Listar usuarios", "actionListUsers": "Listar usuarios",
"actionAddUserRole": "Añadir rol de usuario", "actionAddUserRole": "Añadir rol de usuario",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generar token de acceso", "actionGenerateAccessToken": "Generar token de acceso",
"actionDeleteAccessToken": "Eliminar token de acceso", "actionDeleteAccessToken": "Eliminar token de acceso",
"actionListAccessTokens": "Lista de Tokens de Acceso", "actionListAccessTokens": "Lista de Tokens de Acceso",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Supprimer un utilisateur", "actionRemoveUser": "Supprimer un utilisateur",
"actionListUsers": "Lister les utilisateurs", "actionListUsers": "Lister les utilisateurs",
"actionAddUserRole": "Ajouter un rôle utilisateur", "actionAddUserRole": "Ajouter un rôle utilisateur",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Générer un jeton d'accès", "actionGenerateAccessToken": "Générer un jeton d'accès",
"actionDeleteAccessToken": "Supprimer un jeton d'accès", "actionDeleteAccessToken": "Supprimer un jeton d'accès",
"actionListAccessTokens": "Lister les jetons d'accès", "actionListAccessTokens": "Lister les jetons d'accès",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Rimuovi Utente", "actionRemoveUser": "Rimuovi Utente",
"actionListUsers": "Elenca Utenti", "actionListUsers": "Elenca Utenti",
"actionAddUserRole": "Aggiungi Ruolo Utente", "actionAddUserRole": "Aggiungi Ruolo Utente",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Genera Token di Accesso", "actionGenerateAccessToken": "Genera Token di Accesso",
"actionDeleteAccessToken": "Elimina Token di Accesso", "actionDeleteAccessToken": "Elimina Token di Accesso",
"actionListAccessTokens": "Elenca Token di Accesso", "actionListAccessTokens": "Elenca Token di Accesso",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "사용자 제거", "actionRemoveUser": "사용자 제거",
"actionListUsers": "사용자 목록", "actionListUsers": "사용자 목록",
"actionAddUserRole": "사용자 역할 추가", "actionAddUserRole": "사용자 역할 추가",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "액세스 토큰 생성", "actionGenerateAccessToken": "액세스 토큰 생성",
"actionDeleteAccessToken": "액세스 토큰 삭제", "actionDeleteAccessToken": "액세스 토큰 삭제",
"actionListAccessTokens": "액세스 토큰 목록", "actionListAccessTokens": "액세스 토큰 목록",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Fjern bruker", "actionRemoveUser": "Fjern bruker",
"actionListUsers": "List opp brukere", "actionListUsers": "List opp brukere",
"actionAddUserRole": "Legg til brukerrolle", "actionAddUserRole": "Legg til brukerrolle",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Generer tilgangstoken", "actionGenerateAccessToken": "Generer tilgangstoken",
"actionDeleteAccessToken": "Slett tilgangstoken", "actionDeleteAccessToken": "Slett tilgangstoken",
"actionListAccessTokens": "List opp tilgangstokener", "actionListAccessTokens": "List opp tilgangstokener",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Gebruiker verwijderen", "actionRemoveUser": "Gebruiker verwijderen",
"actionListUsers": "Gebruikers weergeven", "actionListUsers": "Gebruikers weergeven",
"actionAddUserRole": "Gebruikersrol toevoegen", "actionAddUserRole": "Gebruikersrol toevoegen",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Genereer Toegangstoken", "actionGenerateAccessToken": "Genereer Toegangstoken",
"actionDeleteAccessToken": "Verwijder toegangstoken", "actionDeleteAccessToken": "Verwijder toegangstoken",
"actionListAccessTokens": "Lijst toegangstokens", "actionListAccessTokens": "Lijst toegangstokens",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Usuń użytkownika", "actionRemoveUser": "Usuń użytkownika",
"actionListUsers": "Lista użytkowników", "actionListUsers": "Lista użytkowników",
"actionAddUserRole": "Dodaj rolę użytkownika", "actionAddUserRole": "Dodaj rolę użytkownika",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Wygeneruj token dostępu", "actionGenerateAccessToken": "Wygeneruj token dostępu",
"actionDeleteAccessToken": "Usuń token dostępu", "actionDeleteAccessToken": "Usuń token dostępu",
"actionListAccessTokens": "Lista tokenów dostępu", "actionListAccessTokens": "Lista tokenów dostępu",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Remover Utilizador", "actionRemoveUser": "Remover Utilizador",
"actionListUsers": "Listar Utilizadores", "actionListUsers": "Listar Utilizadores",
"actionAddUserRole": "Adicionar Função ao Utilizador", "actionAddUserRole": "Adicionar Função ao Utilizador",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Gerar Token de Acesso", "actionGenerateAccessToken": "Gerar Token de Acesso",
"actionDeleteAccessToken": "Eliminar Token de Acesso", "actionDeleteAccessToken": "Eliminar Token de Acesso",
"actionListAccessTokens": "Listar Tokens de Acesso", "actionListAccessTokens": "Listar Tokens de Acesso",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Удалить пользователя", "actionRemoveUser": "Удалить пользователя",
"actionListUsers": "Список пользователей", "actionListUsers": "Список пользователей",
"actionAddUserRole": "Добавить роль пользователя", "actionAddUserRole": "Добавить роль пользователя",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Сгенерировать токен доступа", "actionGenerateAccessToken": "Сгенерировать токен доступа",
"actionDeleteAccessToken": "Удалить токен доступа", "actionDeleteAccessToken": "Удалить токен доступа",
"actionListAccessTokens": "Список токенов доступа", "actionListAccessTokens": "Список токенов доступа",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "Kullanıcıyı Kaldır", "actionRemoveUser": "Kullanıcıyı Kaldır",
"actionListUsers": "Kullanıcıları Listele", "actionListUsers": "Kullanıcıları Listele",
"actionAddUserRole": "Kullanıcı Rolü Ekle", "actionAddUserRole": "Kullanıcı Rolü Ekle",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "Erişim Jetonu Oluştur", "actionGenerateAccessToken": "Erişim Jetonu Oluştur",
"actionDeleteAccessToken": "Erişim Jetonunu Sil", "actionDeleteAccessToken": "Erişim Jetonunu Sil",
"actionListAccessTokens": "Erişim Jetonlarını Listele", "actionListAccessTokens": "Erişim Jetonlarını Listele",

View File

@@ -1148,6 +1148,7 @@
"actionRemoveUser": "删除用户", "actionRemoveUser": "删除用户",
"actionListUsers": "列出用户", "actionListUsers": "列出用户",
"actionAddUserRole": "添加用户角色", "actionAddUserRole": "添加用户角色",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "生成访问令牌", "actionGenerateAccessToken": "生成访问令牌",
"actionDeleteAccessToken": "删除访问令牌", "actionDeleteAccessToken": "删除访问令牌",
"actionListAccessTokens": "访问令牌", "actionListAccessTokens": "访问令牌",

View File

@@ -1091,6 +1091,7 @@
"actionRemoveUser": "刪除用戶", "actionRemoveUser": "刪除用戶",
"actionListUsers": "列出用戶", "actionListUsers": "列出用戶",
"actionAddUserRole": "添加用戶角色", "actionAddUserRole": "添加用戶角色",
"actionSetUserOrgRoles": "Set User Roles",
"actionGenerateAccessToken": "生成訪問令牌", "actionGenerateAccessToken": "生成訪問令牌",
"actionDeleteAccessToken": "刪除訪問令牌", "actionDeleteAccessToken": "刪除訪問令牌",
"actionListAccessTokens": "訪問令牌", "actionListAccessTokens": "訪問令牌",

View File

@@ -1,9 +1,10 @@
import { Request } from "express"; import { Request } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { userActions, roleActions, userOrgs } from "@server/db"; import { userActions, roleActions } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export enum ActionsEnum { export enum ActionsEnum {
createOrgUser = "createOrgUser", createOrgUser = "createOrgUser",
@@ -53,6 +54,8 @@ export enum ActionsEnum {
listRoleResources = "listRoleResources", listRoleResources = "listRoleResources",
// listRoleActions = "listRoleActions", // listRoleActions = "listRoleActions",
addUserRole = "addUserRole", addUserRole = "addUserRole",
removeUserRole = "removeUserRole",
setUserOrgRoles = "setUserOrgRoles",
// addUserSite = "addUserSite", // addUserSite = "addUserSite",
// addUserAction = "addUserAction", // addUserAction = "addUserAction",
// removeUserAction = "removeUserAction", // removeUserAction = "removeUserAction",
@@ -154,29 +157,16 @@ export async function checkUserActionPermission(
} }
try { try {
let userOrgRoleId = req.userOrgRoleId; let userOrgRoleIds = req.userOrgRoleIds;
// If userOrgRoleId is not available on the request, fetch it if (userOrgRoleIds === undefined) {
if (userOrgRoleId === undefined) { userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!);
const userOrgRole = await db if (userOrgRoleIds.length === 0) {
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, req.userOrgId!)
)
)
.limit(1);
if (userOrgRole.length === 0) {
throw createHttpError( throw createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have access to this organization" "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 // Check if the user has direct permission for the action in the current org
@@ -187,7 +177,7 @@ export async function checkUserActionPermission(
and( and(
eq(userActions.userId, userId), eq(userActions.userId, userId),
eq(userActions.actionId, actionId), 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); .limit(1);
@@ -196,14 +186,14 @@ export async function checkUserActionPermission(
return true; 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 const roleActionPermission = await db
.select() .select()
.from(roleActions) .from(roleActions)
.where( .where(
and( and(
eq(roleActions.actionId, actionId), eq(roleActions.actionId, actionId),
eq(roleActions.roleId, userOrgRoleId!), inArray(roleActions.roleId, userOrgRoleIds),
eq(roleActions.orgId, req.userOrgId!) eq(roleActions.orgId, req.userOrgId!)
) )
) )

View File

@@ -1,26 +1,29 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { roleResources, userResources } from "@server/db"; import { roleResources, userResources } from "@server/db";
export async function canUserAccessResource({ export async function canUserAccessResource({
userId, userId,
resourceId, resourceId,
roleId roleIds
}: { }: {
userId: string; userId: string;
resourceId: number; resourceId: number;
roleId: number; roleIds: number[];
}): Promise<boolean> { }): Promise<boolean> {
const roleResourceAccess = await db const roleResourceAccess =
.select() roleIds.length > 0
.from(roleResources) ? await db
.where( .select()
and( .from(roleResources)
eq(roleResources.resourceId, resourceId), .where(
eq(roleResources.roleId, roleId) and(
) eq(roleResources.resourceId, resourceId),
) inArray(roleResources.roleId, roleIds)
.limit(1); )
)
.limit(1)
: [];
if (roleResourceAccess.length > 0) { if (roleResourceAccess.length > 0) {
return true; return true;

View File

@@ -1,26 +1,29 @@
import { db } from "@server/db"; import { db } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { roleSiteResources, userSiteResources } from "@server/db"; import { roleSiteResources, userSiteResources } from "@server/db";
export async function canUserAccessSiteResource({ export async function canUserAccessSiteResource({
userId, userId,
resourceId, resourceId,
roleId roleIds
}: { }: {
userId: string; userId: string;
resourceId: number; resourceId: number;
roleId: number; roleIds: number[];
}): Promise<boolean> { }): Promise<boolean> {
const roleResourceAccess = await db const roleResourceAccess =
.select() roleIds.length > 0
.from(roleSiteResources) ? await db
.where( .select()
and( .from(roleSiteResources)
eq(roleSiteResources.siteResourceId, resourceId), .where(
eq(roleSiteResources.roleId, roleId) and(
) eq(roleSiteResources.siteResourceId, resourceId),
) inArray(roleSiteResources.roleId, roleIds)
.limit(1); )
)
.limit(1)
: [];
if (roleResourceAccess.length > 0) { if (roleResourceAccess.length > 0) {
return true; return true;

View File

@@ -6,9 +6,11 @@ import {
index, index,
integer, integer,
pgTable, pgTable,
primaryKey,
real, real,
serial, serial,
text, text,
unique,
varchar varchar
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
@@ -335,9 +337,6 @@ export const userOrgs = pgTable("userOrgs", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId),
isOwner: boolean("isOwner").notNull().default(false), isOwner: boolean("isOwner").notNull().default(false),
autoProvisioned: boolean("autoProvisioned").default(false), autoProvisioned: boolean("autoProvisioned").default(false),
pamUsername: varchar("pamUsername") // cleaned username for ssh and such pamUsername: varchar("pamUsername") // cleaned username for ssh and such
@@ -386,6 +385,22 @@ export const roles = pgTable("roles", {
sshUnixGroups: text("sshUnixGroups").default("[]") 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", { export const roleActions = pgTable("roleActions", {
roleId: integer("roleId") roleId: integer("roleId")
.notNull() .notNull()
@@ -453,12 +468,22 @@ export const userInvites = pgTable("userInvites", {
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
email: varchar("email").notNull(), email: varchar("email").notNull(),
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
tokenHash: varchar("token").notNull(), tokenHash: varchar("token").notNull()
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" })
}); });
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", { export const resourcePincode = pgTable("resourcePincode", {
pincodeId: serial("pincodeId").primaryKey(), pincodeId: serial("pincodeId").primaryKey(),
resourceId: integer("resourceId") resourceId: integer("resourceId")
@@ -1034,7 +1059,9 @@ export type UserSite = InferSelectModel<typeof userSites>;
export type RoleResource = InferSelectModel<typeof roleResources>; export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>; export type UserResource = InferSelectModel<typeof userResources>;
export type UserInvite = InferSelectModel<typeof userInvites>; export type UserInvite = InferSelectModel<typeof userInvites>;
export type UserInviteRole = InferSelectModel<typeof userInviteRoles>;
export type UserOrg = InferSelectModel<typeof userOrgs>; export type UserOrg = InferSelectModel<typeof userOrgs>;
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>; export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>; export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>; export type ResourcePassword = InferSelectModel<typeof resourcePassword>;

View File

@@ -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 { import {
Resource, Resource,
ResourcePassword, ResourcePassword,
@@ -12,13 +20,12 @@ import {
resources, resources,
roleResources, roleResources,
sessions, sessions,
userOrgs,
userResources, userResources,
users, users,
ResourceHeaderAuthExtendedCompatibility, ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility resourceHeaderAuthExtendedCompatibility
} from "@server/db"; } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
export type ResourceWithAuth = { export type ResourceWithAuth = {
resource: Resource | null; 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) { export async function getRoleName(roleId: number): Promise<string | null> {
const userOrgRole = await db const [row] = await db
.select({ .select({ name: roles.name })
userId: userOrgs.userId, .from(roles)
orgId: userOrgs.orgId, .where(eq(roles.roleId, roleId))
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))
.limit(1); .limit(1);
return row?.name ?? null;
return userOrgRole.length > 0 ? userOrgRole[0] : null;
} }
/** /**
@@ -129,7 +127,7 @@ export async function getUserOrgRole(userId: string, orgId: string) {
*/ */
export async function getRoleResourceAccess( export async function getRoleResourceAccess(
resourceId: number, resourceId: number,
roleId: number roleIds: number[]
) { ) {
const roleResourceAccess = await db const roleResourceAccess = await db
.select() .select()
@@ -137,12 +135,11 @@ export async function getRoleResourceAccess(
.where( .where(
and( and(
eq(roleResources.resourceId, resourceId), 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;
} }
/** /**

View File

@@ -1,6 +1,13 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm"; 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", { export const domains = sqliteTable("domains", {
domainId: text("domainId").primaryKey(), domainId: text("domainId").primaryKey(),
@@ -643,9 +650,6 @@ export const userOrgs = sqliteTable("userOrgs", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId),
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
autoProvisioned: integer("autoProvisioned", { autoProvisioned: integer("autoProvisioned", {
mode: "boolean" mode: "boolean"
@@ -700,6 +704,22 @@ export const roles = sqliteTable("roles", {
sshUnixGroups: text("sshUnixGroups").default("[]") 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", { export const roleActions = sqliteTable("roleActions", {
roleId: integer("roleId") roleId: integer("roleId")
.notNull() .notNull()
@@ -785,12 +805,22 @@ export const userInvites = sqliteTable("userInvites", {
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
email: text("email").notNull(), email: text("email").notNull(),
expiresAt: integer("expiresAt").notNull(), expiresAt: integer("expiresAt").notNull(),
tokenHash: text("token").notNull(), tokenHash: text("token").notNull()
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" })
}); });
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", { export const resourcePincode = sqliteTable("resourcePincode", {
pincodeId: integer("pincodeId").primaryKey({ pincodeId: integer("pincodeId").primaryKey({
autoIncrement: true autoIncrement: true
@@ -1133,7 +1163,9 @@ export type UserSite = InferSelectModel<typeof userSites>;
export type RoleResource = InferSelectModel<typeof roleResources>; export type RoleResource = InferSelectModel<typeof roleResources>;
export type UserResource = InferSelectModel<typeof userResources>; export type UserResource = InferSelectModel<typeof userResources>;
export type UserInvite = InferSelectModel<typeof userInvites>; export type UserInvite = InferSelectModel<typeof userInvites>;
export type UserInviteRole = InferSelectModel<typeof userInviteRoles>;
export type UserOrg = InferSelectModel<typeof userOrgs>; export type UserOrg = InferSelectModel<typeof userOrgs>;
export type UserOrgRole = InferSelectModel<typeof userOrgRoles>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>; export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>; export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>; export type ResourcePassword = InferSelectModel<typeof resourcePassword>;

View File

@@ -74,7 +74,7 @@ declare global {
session: Session; session: Session;
userOrg?: UserOrg; userOrg?: UserOrg;
apiKeyOrg?: ApiKeyOrg; apiKeyOrg?: ApiKeyOrg;
userOrgRoleId?: number; userOrgRoleIds?: number[];
userOrgId?: string; userOrgId?: string;
userOrgIds?: string[]; userOrgIds?: string[];
remoteExitNode?: RemoteExitNode; remoteExitNode?: RemoteExitNode;

View File

@@ -15,7 +15,8 @@ export enum TierFeature {
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
SshPam = "sshPam" SshPam = "sshPam",
FullRbac = "fullRbac"
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -48,5 +49,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
"enterprise" "enterprise"
], ],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"]
}; };

View File

@@ -10,6 +10,7 @@ import {
roles, roles,
Transaction, Transaction,
userClients, userClients,
userOrgRoles,
userOrgs userOrgs
} from "@server/db"; } from "@server/db";
import { getUniqueClientName } from "@server/db/names"; import { getUniqueClientName } from "@server/db/names";
@@ -39,20 +40,36 @@ export async function calculateUserClientsForOrgs(
return; return;
} }
// Get all user orgs // Get all user orgs with all roles (for org list and role-based logic)
const allUserOrgs = await transaction const userOrgRoleRows = await transaction
.select() .select()
.from(userOrgs) .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)); .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 each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) { for (const olm of userOlms) {
for (const userRoleOrg of allUserOrgs) { for (const orgId of orgIdToRoleRows.keys()) {
const { userOrgs: userOrg, roles: role } = userRoleOrg; const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const orgId = userOrg.orgId; const userOrg = roleRowsForOrg[0].userOrgs;
const [org] = await transaction const [org] = await transaction
.select() .select()
@@ -196,7 +213,7 @@ export async function calculateUserClientsForOrgs(
const requireApproval = const requireApproval =
build !== "oss" && build !== "oss" &&
isOrgLicensed && isOrgLicensed &&
role.requireDeviceApproval; roleRowsForOrg.some((r) => r.roles.requireDeviceApproval);
const newClientData: InferInsertModel<typeof clients> = { const newClientData: InferInsertModel<typeof clients> = {
userId, userId,

View File

@@ -79,6 +79,7 @@ export const configSchema = z
.default(3001) .default(3001)
.transform(stoi) .transform(stoi)
.pipe(portSchema), .pipe(portSchema),
badger_override: z.string().optional(),
next_port: portSchema next_port: portSchema
.optional() .optional()
.default(3002) .default(3002)

View File

@@ -14,6 +14,7 @@ import {
siteResources, siteResources,
sites, sites,
Transaction, Transaction,
userOrgRoles,
userOrgs, userOrgs,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
@@ -77,10 +78,10 @@ export async function getClientSiteResourceAccess(
// get all of the users in these roles // get all of the users in these roles
const userIdsFromRoles = await trx const userIdsFromRoles = await trx
.select({ .select({
userId: userOrgs.userId userId: userOrgRoles.userId
}) })
.from(userOrgs) .from(userOrgRoles)
.where(inArray(userOrgs.roleId, roleIds)) .where(inArray(userOrgRoles.roleId, roleIds))
.then((rows) => rows.map((row) => row.userId)); .then((rows) => rows.map((row) => row.userId));
const newAllUserIds = Array.from( const newAllUserIds = Array.from(
@@ -814,12 +815,12 @@ export async function rebuildClientAssociationsFromClient(
// Role-based access // Role-based access
const roleIds = await trx const roleIds = await trx
.select({ roleId: userOrgs.roleId }) .select({ roleId: userOrgRoles.roleId })
.from(userOrgs) .from(userOrgRoles)
.where( .where(
and( and(
eq(userOrgs.userId, client.userId), eq(userOrgRoles.userId, client.userId),
eq(userOrgs.orgId, client.orgId) eq(userOrgRoles.orgId, client.orgId)
) )
) // this needs to be locked onto this org or else cross-org access could happen ) // this needs to be locked onto this org or else cross-org access could happen
.then((rows) => rows.map((row) => row.roleId)); .then((rows) => rows.map((row) => row.roleId));

View File

@@ -6,7 +6,7 @@ import {
siteResources, siteResources,
sites, sites,
Transaction, Transaction,
UserOrg, userOrgRoles,
userOrgs, userOrgs,
userResources, userResources,
userSiteResources, userSiteResources,
@@ -19,9 +19,22 @@ import { FeatureId } from "@server/lib/billing";
export async function assignUserToOrg( export async function assignUserToOrg(
org: Org, org: Org,
values: typeof userOrgs.$inferInsert, values: typeof userOrgs.$inferInsert,
roleIds: number[],
trx: Transaction | typeof db = db 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(); 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 // 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) { if (org.billingOrgId) {
@@ -58,6 +71,14 @@ export async function removeUserFromOrg(
userId: string, userId: string,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
) { ) {
await trx
.delete(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, org.orgId)
)
);
await trx await trx
.delete(userOrgs) .delete(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId))); .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));

View File

@@ -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<number[]> {
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;
}

View File

@@ -21,8 +21,7 @@ export async function getUserOrgs(
try { try {
const userOrganizations = await db const userOrganizations = await db
.select({ .select({
orgId: userOrgs.orgId, orgId: userOrgs.orgId
roleId: userOrgs.roleId
}) })
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.userId, userId)); .where(eq(userOrgs.userId, userId));

View File

@@ -17,6 +17,7 @@ export * from "./verifyAccessTokenAccess";
export * from "./requestTimeout"; export * from "./requestTimeout";
export * from "./verifyClientAccess"; export * from "./verifyClientAccess";
export * from "./verifyUserHasAction"; export * from "./verifyUserHasAction";
export * from "./verifyUserCanSetUserOrgRoles";
export * from "./verifyUserIsServerAdmin"; export * from "./verifyUserIsServerAdmin";
export * from "./verifyIsLoggedInUser"; export * from "./verifyIsLoggedInUser";
export * from "./verifyIsLoggedInUser"; export * from "./verifyIsLoggedInUser";

View File

@@ -1,6 +1,7 @@
export * from "./verifyApiKey"; export * from "./verifyApiKey";
export * from "./verifyApiKeyOrgAccess"; export * from "./verifyApiKeyOrgAccess";
export * from "./verifyApiKeyHasAction"; export * from "./verifyApiKeyHasAction";
export * from "./verifyApiKeyCanSetUserOrgRoles";
export * from "./verifyApiKeySiteAccess"; export * from "./verifyApiKeySiteAccess";
export * from "./verifyApiKeyResourceAccess"; export * from "./verifyApiKeyResourceAccess";
export * from "./verifyApiKeyTargetAccess"; export * from "./verifyApiKeyTargetAccess";

View File

@@ -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<any> {
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"
)
);
}
};
}

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "@server/auth/canUserAccessResource"; import { canUserAccessResource } from "@server/auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyAccessTokenAccess( export async function verifyAccessTokenAccess(
req: Request, req: Request,
@@ -93,7 +94,10 @@ export async function verifyAccessTokenAccess(
) )
); );
} else { } else {
req.userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
resource[0].orgId!
);
req.userOrgId = resource[0].orgId!; req.userOrgId = resource[0].orgId!;
} }
@@ -118,7 +122,7 @@ export async function verifyAccessTokenAccess(
const resourceAllowed = await canUserAccessResource({ const resourceAllowed = await canUserAccessResource({
userId, userId,
resourceId, resourceId,
roleId: req.userOrgRoleId! roleIds: req.userOrgRoleIds ?? []
}); });
if (!resourceAllowed) { if (!resourceAllowed) {

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { roles, userOrgs } 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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyAdmin( export async function verifyAdmin(
req: Request, 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() .select()
.from(roles) .from(roles)
.where(eq(roles.roleId, req.userOrg.roleId)) .where(
and(
inArray(roles.roleId, req.userOrgRoleIds),
eq(roles.isAdmin, true)
)
)
.limit(1); .limit(1);
if (userRole.length === 0 || !userRole[0].isAdmin) { if (userAdminRoles.length === 0) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { userOrgs, apiKeys, apiKeyOrg } 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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyApiKeyAccess( export async function verifyApiKeyAccess(
req: Request, req: Request,
@@ -103,8 +104,10 @@ export async function verifyApiKeyAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
orgId
);
return next(); return next();
} catch (error) { } catch (error) {

View File

@@ -1,11 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { Client, db } from "@server/db"; import { Client, db } from "@server/db";
import { userOrgs, clients, roleClients, userClients } 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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import logger from "@server/logger"; import logger from "@server/logger";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyClientAccess( export async function verifyClientAccess(
req: Request, req: Request,
@@ -113,21 +114,30 @@ export async function verifyClientAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
client.orgId
);
req.userOrgId = client.orgId; req.userOrgId = client.orgId;
// Check role-based site access first // Check role-based client access (any of user's roles)
const [roleClientAccess] = await db const roleClientAccessList =
.select() (req.userOrgRoleIds?.length ?? 0) > 0
.from(roleClients) ? await db
.where( .select()
and( .from(roleClients)
eq(roleClients.clientId, client.clientId), .where(
eq(roleClients.roleId, userOrgRoleId) and(
) eq(roleClients.clientId, client.clientId),
) inArray(
.limit(1); roleClients.roleId,
req.userOrgRoleIds!
)
)
)
.limit(1)
: [];
const [roleClientAccess] = roleClientAccessList;
if (roleClientAccess) { if (roleClientAccess) {
// User has access to the site through their role // User has access to the site through their role

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, domains, orgDomains } from "@server/db"; 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 { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyDomainAccess( export async function verifyDomainAccess(
req: Request, req: Request,
@@ -63,7 +64,7 @@ export async function verifyDomainAccess(
.where( .where(
and( and(
eq(userOrgs.userId, userId), eq(userOrgs.userId, userId),
eq(userOrgs.orgId, apiKeyOrg.orgId) eq(userOrgs.orgId, orgId)
) )
) )
.limit(1); .limit(1);
@@ -97,8 +98,7 @@ export async function verifyDomainAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.userOrgRoleId = userOrgRoleId;
return next(); return next();
} catch (error) { } catch (error) {

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, orgs } from "@server/db"; import { db } from "@server/db";
import { userOrgs } from "@server/db"; import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyOrgAccess( export async function verifyOrgAccess(
req: Request, 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 // User has access, attach the user's role(s) to the request for potential future use
req.userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
req.userOrgId = orgId; req.userOrgId = orgId;
return next(); return next();

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, Resource } from "@server/db"; import { db, Resource } from "@server/db";
import { resources, userOrgs, userResources, roleResources } 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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyResourceAccess( export async function verifyResourceAccess(
req: Request, req: Request,
@@ -107,20 +108,28 @@ export async function verifyResourceAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
resource.orgId
);
req.userOrgId = resource.orgId; req.userOrgId = resource.orgId;
const roleResourceAccess = await db const roleResourceAccess =
.select() (req.userOrgRoleIds?.length ?? 0) > 0
.from(roleResources) ? await db
.where( .select()
and( .from(roleResources)
eq(roleResources.resourceId, resource.resourceId), .where(
eq(roleResources.roleId, userOrgRoleId) and(
) eq(roleResources.resourceId, resource.resourceId),
) inArray(
.limit(1); roleResources.roleId,
req.userOrgRoleIds!
)
)
)
.limit(1)
: [];
if (roleResourceAccess.length > 0) { if (roleResourceAccess.length > 0) {
return next(); return next();

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger"; import logger from "@server/logger";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyRoleAccess( export async function verifyRoleAccess(
req: Request, req: Request,
@@ -99,7 +100,6 @@ export async function verifyRoleAccess(
} }
if (!req.userOrg) { if (!req.userOrg) {
// get the userORg
const userOrg = await db const userOrg = await db
.select() .select()
.from(userOrgs) .from(userOrgs)
@@ -109,7 +109,7 @@ export async function verifyRoleAccess(
.limit(1); .limit(1);
req.userOrg = userOrg[0]; req.userOrg = userOrg[0];
req.userOrgRoleId = userOrg[0].roleId; req.userOrgRoleIds = await getUserOrgRoleIds(userId, orgId!);
} }
if (!req.userOrg) { if (!req.userOrg) {

View File

@@ -1,10 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db } from "@server/db"; import { db } from "@server/db";
import { sites, Site, userOrgs, userSites, roleSites, roles } 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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifySiteAccess( export async function verifySiteAccess(
req: Request, req: Request,
@@ -112,21 +113,29 @@ export async function verifySiteAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
site.orgId
);
req.userOrgId = site.orgId; req.userOrgId = site.orgId;
// Check role-based site access first // Check role-based site access first (any of user's roles)
const roleSiteAccess = await db const roleSiteAccess =
.select() (req.userOrgRoleIds?.length ?? 0) > 0
.from(roleSites) ? await db
.where( .select()
and( .from(roleSites)
eq(roleSites.siteId, site.siteId), .where(
eq(roleSites.roleId, userOrgRoleId) and(
) eq(roleSites.siteId, site.siteId),
) inArray(
.limit(1); roleSites.roleId,
req.userOrgRoleIds!
)
)
)
.limit(1)
: [];
if (roleSiteAccess.length > 0) { if (roleSiteAccess.length > 0) {
// User's role has access to the site // User's role has access to the site

View File

@@ -1,11 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db"; import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db";
import { siteResources } 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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger"; import logger from "@server/logger";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifySiteResourceAccess( export async function verifySiteResourceAccess(
req: Request, req: Request,
@@ -109,23 +110,34 @@ export async function verifySiteResourceAccess(
} }
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
siteResource.orgId
);
req.userOrgId = siteResource.orgId; req.userOrgId = siteResource.orgId;
// Attach the siteResource to the request for use in the next middleware/route // Attach the siteResource to the request for use in the next middleware/route
req.siteResource = siteResource; req.siteResource = siteResource;
const roleResourceAccess = await db const roleResourceAccess =
.select() (req.userOrgRoleIds?.length ?? 0) > 0
.from(roleSiteResources) ? await db
.where( .select()
and( .from(roleSiteResources)
eq(roleSiteResources.siteResourceId, siteResourceIdNum), .where(
eq(roleSiteResources.roleId, userOrgRoleId) and(
) eq(
) roleSiteResources.siteResourceId,
.limit(1); siteResourceIdNum
),
inArray(
roleSiteResources.roleId,
req.userOrgRoleIds!
)
)
)
.limit(1)
: [];
if (roleResourceAccess.length > 0) { if (roleResourceAccess.length > 0) {
return next(); return next();

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "../auth/canUserAccessResource"; import { canUserAccessResource } from "../auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyTargetAccess( export async function verifyTargetAccess(
req: Request, req: Request,
@@ -99,7 +100,10 @@ export async function verifyTargetAccess(
) )
); );
} else { } else {
req.userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
resource[0].orgId!
);
req.userOrgId = resource[0].orgId!; req.userOrgId = resource[0].orgId!;
} }
@@ -126,7 +130,7 @@ export async function verifyTargetAccess(
const resourceAllowed = await canUserAccessResource({ const resourceAllowed = await canUserAccessResource({
userId, userId,
resourceId, resourceId,
roleId: req.userOrgRoleId! roleIds: req.userOrgRoleIds ?? []
}); });
if (!resourceAllowed) { if (!resourceAllowed) {

View File

@@ -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<any> {
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"
)
);
}
};
}

View File

@@ -12,7 +12,7 @@ export async function verifyUserInRole(
const roleId = parseInt( const roleId = parseInt(
req.params.roleId || req.body.roleId || req.query.roleId req.params.roleId || req.body.roleId || req.query.roleId
); );
const userRoleId = req.userOrgRoleId; const userOrgRoleIds = req.userOrgRoleIds ?? [];
if (isNaN(roleId)) { if (isNaN(roleId)) {
return next( return next(
@@ -20,7 +20,7 @@ export async function verifyUserInRole(
); );
} }
if (!userRoleId) { if (userOrgRoleIds.length === 0) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
@@ -29,7 +29,7 @@ export async function verifyUserInRole(
); );
} }
if (userRoleId !== roleId) { if (!userOrgRoleIds.includes(roleId)) {
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,

View File

@@ -13,9 +13,10 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { userOrgs, db, idp, idpOrg } from "@server/db"; 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 createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyIdpAccess( export async function verifyIdpAccess(
req: Request, req: Request,
@@ -84,8 +85,10 @@ export async function verifyIdpAccess(
); );
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
idpRes.idpOrg.orgId
);
return next(); return next();
} catch (error) { } catch (error) {

View File

@@ -12,11 +12,12 @@
*/ */
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { db, exitNodeOrgs, exitNodes, remoteExitNodes } from "@server/db"; import { db, exitNodeOrgs, remoteExitNodes } from "@server/db";
import { sites, userOrgs, userSites, roleSites, roles } from "@server/db"; import { userOrgs } from "@server/db";
import { and, eq, or } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyRemoteExitNodeAccess( export async function verifyRemoteExitNodeAccess(
req: Request, req: Request,
@@ -103,8 +104,10 @@ export async function verifyRemoteExitNodeAccess(
); );
} }
const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrgRoleId = userOrgRoleId; req.userOrg.userId,
exitNodeOrg.orgId
);
return next(); return next();
} catch (error) { } catch (error) {

View File

@@ -26,9 +26,10 @@ import {
orgs, orgs,
resources, resources,
roles, roles,
siteResources siteResources,
userOrgRoles
} from "@server/db"; } from "@server/db";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
/** /**
* Get the maximum allowed retention days for a given tier * Get the maximum allowed retention days for a given tier
@@ -291,6 +292,10 @@ async function disableFeature(
await disableSshPam(orgId); await disableSshPam(orgId);
break; break;
case TierFeature.FullRbac:
await disableFullRbac(orgId);
break;
default: default:
logger.warn( logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping` `Unknown feature ${feature} for org ${orgId}, skipping`
@@ -326,6 +331,10 @@ async function disableSshPam(orgId: string): Promise<void> {
); );
} }
async function disableFullRbac(orgId: string): Promise<void> {
logger.info(`Disabled full RBAC for org ${orgId}`);
}
async function disableLoginPageBranding(orgId: string): Promise<void> { async function disableLoginPageBranding(orgId: string): Promise<void> {
const [existingBranding] = await db const [existingBranding] = await db
.select() .select()

View File

@@ -26,6 +26,7 @@ import * as misc from "#private/routers/misc";
import * as reKey from "#private/routers/re-key"; import * as reKey from "#private/routers/re-key";
import * as approval from "#private/routers/approvals"; import * as approval from "#private/routers/approvals";
import * as ssh from "#private/routers/ssh"; import * as ssh from "#private/routers/ssh";
import * as user from "#private/routers/user";
import { import {
verifyOrgAccess, verifyOrgAccess,
@@ -33,7 +34,10 @@ import {
verifyUserIsServerAdmin, verifyUserIsServerAdmin,
verifySiteAccess, verifySiteAccess,
verifyClientAccess, verifyClientAccess,
verifyLimits verifyLimits,
verifyRoleAccess,
verifyUserAccess,
verifyUserCanSetUserOrgRoles
} from "@server/middlewares"; } from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import { import {
@@ -518,3 +522,33 @@ authenticated.post(
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata
ssh.signSshKey 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
);

View File

@@ -52,7 +52,9 @@ import {
userOrgs, userOrgs,
roleResources, roleResources,
userResources, userResources,
resourceRules resourceRules,
userOrgRoles,
roles
} from "@server/db"; } from "@server/db";
import { eq, and, inArray, isNotNull, ne } from "drizzle-orm"; import { eq, and, inArray, isNotNull, ne } from "drizzle-orm";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
@@ -104,6 +106,13 @@ const getUserOrgSessionVerifySchema = z.strictObject({
sessionId: z.string().min(1, "Session ID is required") 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({ const getRoleResourceAccessParamsSchema = z.strictObject({
roleId: z roleId: z
.string() .string()
@@ -115,6 +124,23 @@ const getRoleResourceAccessParamsSchema = z.strictObject({
.pipe(z.int().positive("Resource ID must be a positive integer")) .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({ const getUserResourceAccessParamsSchema = z.strictObject({
userId: z.string().min(1, "User ID is required"), userId: z.string().min(1, "User ID is required"),
resourceId: z resourceId: z
@@ -760,7 +786,7 @@ hybridRouter.get(
// Get user organization role // Get user organization role
hybridRouter.get( hybridRouter.get(
"/user/:userId/org/:orgId/role", "/user/:userId/org/:orgId/roles",
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const parsedParams = getUserOrgRoleParamsSchema.safeParse( const parsedParams = getUserOrgRoleParamsSchema.safeParse(
@@ -796,23 +822,129 @@ hybridRouter.get(
); );
} }
const userOrgRole = await db const userOrgRoleRows = await db
.select() .select({ roleId: userOrgRoles.roleId, roleName: roles.name })
.from(userOrgs) .from(userOrgRoles)
.innerJoin(roles, eq(roles.roleId, userOrgRoles.roleId))
.where( .where(
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) and(
) eq(userOrgRoles.userId, userId),
.limit(1); eq(userOrgRoles.orgId, orgId)
)
);
const result = userOrgRole.length > 0 ? userOrgRole[0] : null; logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows);
return response<typeof userOrgs.$inferSelect | null>(res, { return response<{ roleId: number, roleName: string }[]>(res, {
data: result, data: userOrgRoleRows,
success: true, success: true,
error: false, error: false,
message: result message:
? "User org role retrieved successfully" userOrgRoleRows.length > 0
: "User org role not found", ? "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 status: HttpCode.OK
}); });
} catch (error) { } 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<string | null>(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 // Check if role has access to resource
hybridRouter.get( hybridRouter.get(
"/role/:roleId/resource/:resourceId/access", "/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 // Check if user has direct access to resource
hybridRouter.get( hybridRouter.get(
"/user/:userId/resource/:resourceId/access", "/user/:userId/resource/:resourceId/access",
@@ -1873,7 +2154,8 @@ hybridRouter.post(
// userAgent: data.userAgent, // TODO: add this // userAgent: data.userAgent, // TODO: add this
// headers: data.body.headers, // headers: data.body.headers,
// query: data.body.query, // query: data.body.query,
originalRequestURL: sanitizeString(logEntry.originalRequestURL) ?? "", originalRequestURL:
sanitizeString(logEntry.originalRequestURL) ?? "",
scheme: sanitizeString(logEntry.scheme) ?? "", scheme: sanitizeString(logEntry.scheme) ?? "",
host: sanitizeString(logEntry.host) ?? "", host: sanitizeString(logEntry.host) ?? "",
path: sanitizeString(logEntry.path) ?? "", path: sanitizeString(logEntry.path) ?? "",

View File

@@ -20,8 +20,11 @@ import {
verifyApiKeyIsRoot, verifyApiKeyIsRoot,
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
verifyApiKeyIdpAccess, verifyApiKeyIdpAccess,
verifyApiKeyRoleAccess,
verifyApiKeyUserAccess,
verifyLimits verifyLimits
} from "@server/middlewares"; } from "@server/middlewares";
import * as user from "#private/routers/user";
import { import {
verifyValidSubscription, verifyValidSubscription,
verifyValidLicense verifyValidLicense
@@ -140,3 +143,23 @@ authenticated.get(
verifyApiKeyHasAction(ActionsEnum.listIdps), verifyApiKeyHasAction(ActionsEnum.listIdps),
orgIdp.listOrgIdps 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
);

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; 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 { eq, and, or } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -95,7 +95,14 @@ async function getOrgAdmins(orgId: string) {
}) })
.from(userOrgs) .from(userOrgs)
.innerJoin(users, eq(userOrgs.userId, users.userId)) .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( .where(
and( and(
eq(userOrgs.orgId, orgId), eq(userOrgs.orgId, orgId),
@@ -103,8 +110,11 @@ async function getOrgAdmins(orgId: string) {
) )
); );
// Filter to only include users with verified emails // Dedupe by userId (user may have multiple roles)
const orgAdmins = admins.filter( const byUserId = new Map(
admins.map((a) => [a.userId, a])
);
const orgAdmins = Array.from(byUserId.values()).filter(
(admin) => admin.email && admin.email.length > 0 (admin) => admin.email && admin.email.length > 0
); );

View File

@@ -79,7 +79,7 @@ export async function createRemoteExitNode(
const { remoteExitNodeId, secret } = parsedBody.data; const { remoteExitNodeId, secret } = parsedBody.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );

View File

@@ -32,7 +32,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; 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 { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA"; import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -126,7 +126,7 @@ export async function signSshKey(
resource: resourceQueryString resource: resourceQueryString
} = parsedBody.data; } = parsedBody.data;
const userId = req.user?.userId; const userId = req.user?.userId;
const roleId = req.userOrgRoleId!; const roleIds = req.userOrgRoleIds ?? [];
if (!userId) { if (!userId) {
return next( 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 const [userOrg] = await db
.select() .select()
.from(userOrgs) .from(userOrgs)
@@ -340,7 +349,7 @@ export async function signSshKey(
const hasAccess = await canUserAccessSiteResource({ const hasAccess = await canUserAccessSiteResource({
userId: userId, userId: userId,
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
roleId: roleId roleIds
}); });
if (!hasAccess) { if (!hasAccess) {
@@ -352,28 +361,39 @@ export async function signSshKey(
); );
} }
const [roleRow] = await db const roleRows = await db
.select() .select()
.from(roles) .from(roles)
.where(eq(roles.roleId, roleId)) .where(inArray(roles.roleId, roleIds));
.limit(1);
let parsedSudoCommands: string[] = []; const parsedSudoCommands: string[] = [];
let parsedGroups: string[] = []; const parsedGroupsSet = new Set<string>();
try { let homedir: boolean | null = null;
parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); const sudoModeOrder = { none: 0, commands: 1, all: 2 };
if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = []; let sudoMode: "none" | "commands" | "all" = "none";
} catch { for (const roleRow of roleRows) {
parsedSudoCommands = []; 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 { const parsedGroups = Array.from(parsedGroupsSet);
parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); if (homedir === null && roleRows.length > 0) {
if (!Array.isArray(parsedGroups)) parsedGroups = []; homedir = roleRows[0].sshCreateHomeDir ?? null;
} catch {
parsedGroups = [];
} }
const homedir = roleRow?.sshCreateHomeDir ?? null;
const sudoMode = roleRow?.sshSudoMode ?? "none";
// get the site // get the site
const [newt] = await db const [newt] = await db

View File

@@ -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 { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { clients, db, UserOrg } from "@server/db"; import stoi from "@server/lib/stoi";
import { userOrgs, roles } from "@server/db"; import { clients, db } from "@server/db";
import { userOrgRoles, userOrgs, roles } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import stoi from "@server/lib/stoi";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
@@ -17,11 +30,9 @@ const addUserRoleParamsSchema = z.strictObject({
roleId: z.string().transform(stoi).pipe(z.number()) roleId: z.string().transform(stoi).pipe(z.number())
}); });
export type AddUserRoleResponse = z.infer<typeof addUserRoleParamsSchema>;
registry.registerPath({ registry.registerPath({
method: "post", method: "post",
path: "/role/{roleId}/add/{userId}", path: "/user/{userId}/add-role/{roleId}",
description: "Add a role to a user.", description: "Add a role to a user.",
tags: [OpenAPITags.Role, OpenAPITags.User], tags: [OpenAPITags.Role, OpenAPITags.User],
request: { 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) => { await db.transaction(async (trx) => {
[newUserRole] = await trx const inserted = await trx
.update(userOrgs) .insert(userOrgRoles)
.set({ roleId }) .values({
.where( userId,
and( orgId: role.orgId,
eq(userOrgs.userId, userId), roleId
eq(userOrgs.orgId, role.orgId) })
) .onConflictDoNothing()
)
.returning(); .returning();
// get the client associated with this user in this org if (inserted.length > 0) {
newUserRole = inserted[0];
}
const orgClients = await trx const orgClients = await trx
.select() .select()
.from(clients) .from(clients)
@@ -133,17 +147,15 @@ export async function addUserRole(
eq(clients.userId, userId), eq(clients.userId, userId),
eq(clients.orgId, role.orgId) eq(clients.orgId, role.orgId)
) )
) );
.limit(1);
for (const orgClient of orgClients) { 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); await rebuildClientAssociationsFromClient(orgClient, trx);
} }
}); });
return response(res, { return response(res, {
data: newUserRole, data: newUserRole ?? { userId, orgId: role.orgId, roleId },
success: true, success: true,
error: false, error: false,
message: "Role added to user successfully", message: "Role added to user successfully",

View File

@@ -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";

View File

@@ -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<any> {
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")
);
}
}

View File

@@ -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<any> {
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")
);
}
}

View File

@@ -208,7 +208,7 @@ export async function listAccessTokens(
.where( .where(
or( or(
eq(userResources.userId, req.user!.userId), eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!) inArray(roleResources.roleId, req.userOrgRoleIds!)
) )
); );
} else { } else {

View File

@@ -4,11 +4,11 @@ import {
getResourceByDomain, getResourceByDomain,
getResourceRules, getResourceRules,
getRoleResourceAccess, getRoleResourceAccess,
getUserOrgRole,
getUserResourceAccess, getUserResourceAccess,
getOrgLoginPage, getOrgLoginPage,
getUserSessionWithUser getUserSessionWithUser
} from "@server/db/queries/verifySessionQueries"; } from "@server/db/queries/verifySessionQueries";
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
import { import {
LoginPage, LoginPage,
Org, Org,
@@ -30,7 +30,6 @@ import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { getCountryCodeForIp } from "@server/lib/geoip"; import { getCountryCodeForIp } from "@server/lib/geoip";
import { getAsnForIp } from "@server/lib/asn"; import { getAsnForIp } from "@server/lib/asn";
import { getOrgTierData } from "#dynamic/lib/billing";
import { verifyPassword } from "@server/auth/password"; import { verifyPassword } from "@server/auth/password";
import { import {
checkOrgAccessPolicy, checkOrgAccessPolicy,
@@ -797,7 +796,8 @@ async function notAllowed(
) { ) {
let loginPage: LoginPage | null = null; let loginPage: LoginPage | null = null;
if (orgId) { 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, orgId,
tierMatrix.loginPageDomain tierMatrix.loginPageDomain
); );
@@ -854,7 +854,10 @@ async function headerAuthChallenged(
) { ) {
let loginPage: LoginPage | null = null; let loginPage: LoginPage | null = null;
if (orgId) { 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) { if (subscribed) {
loginPage = await getOrgLoginPage(orgId); loginPage = await getOrgLoginPage(orgId);
} }
@@ -916,9 +919,9 @@ async function isUserAllowedToAccessResource(
return null; 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; return null;
} }
@@ -936,15 +939,14 @@ async function isUserAllowedToAccessResource(
const roleResourceAccess = await getRoleResourceAccess( const roleResourceAccess = await getRoleResourceAccess(
resource.resourceId, resource.resourceId,
userOrgRole.roleId userOrgRoles.map((r) => r.roleId)
); );
if (roleResourceAccess && roleResourceAccess.length > 0) {
if (roleResourceAccess) {
return { return {
username: user.username, username: user.username,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: userOrgRole.roleName role: userOrgRoles.map((r) => r.roleName).join(", ")
}; };
} }
@@ -958,7 +960,7 @@ async function isUserAllowedToAccessResource(
username: user.username, username: user.username,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: userOrgRole.roleName role: userOrgRoles.map((r) => r.roleName).join(", ")
}; };
} }

View File

@@ -92,7 +92,7 @@ export async function createClient(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );
@@ -234,7 +234,7 @@ export async function createClient(
clientId: newClient.clientId 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 // make sure the user can access the client
trx.insert(userClients).values({ trx.insert(userClients).values({
userId: req.user.userId, userId: req.user.userId,

View File

@@ -297,7 +297,7 @@ export async function listClients(
.where( .where(
or( or(
eq(userClients.userId, req.user!.userId), eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!) inArray(roleClients.roleId, req.userOrgRoleIds!)
) )
); );
} else { } else {

View File

@@ -316,7 +316,7 @@ export async function listUserDevices(
.where( .where(
or( or(
eq(userClients.userId, req.user!.userId), eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!) inArray(roleClients.roleId, req.userOrgRoleIds!)
) )
); );
} else { } else {

View File

@@ -644,6 +644,7 @@ authenticated.delete(
logActionAudit(ActionsEnum.deleteRole), logActionAudit(ActionsEnum.deleteRole),
role.deleteRole role.deleteRole
); );
authenticated.post( authenticated.post(
"/role/:roleId/add/:userId", "/role/:roleId/add/:userId",
verifyRoleAccess, verifyRoleAccess,
@@ -651,7 +652,7 @@ authenticated.post(
verifyLimits, verifyLimits,
verifyUserHasAction(ActionsEnum.addUserRole), verifyUserHasAction(ActionsEnum.addUserRole),
logActionAudit(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole),
user.addUserRole user.addUserRoleLegacy
); );
authenticated.post( authenticated.post(

View File

@@ -25,7 +25,8 @@ const bodySchema = z.strictObject({
namePath: z.string().optional(), namePath: z.string().optional(),
scopes: z.string().nonempty(), scopes: z.string().nonempty(),
autoProvision: z.boolean().optional(), autoProvision: z.boolean().optional(),
tags: z.string().optional() tags: z.string().optional(),
variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc")
}); });
export type CreateIdpResponse = { export type CreateIdpResponse = {
@@ -77,7 +78,8 @@ export async function createOidcIdp(
namePath, namePath,
name, name,
autoProvision, autoProvision,
tags tags,
variant
} = parsedBody.data; } = parsedBody.data;
if ( if (
@@ -121,7 +123,8 @@ export async function createOidcIdp(
scopes, scopes,
identifierPath, identifierPath,
emailPath, emailPath,
namePath namePath,
variant
}); });
}); });

View File

@@ -31,7 +31,8 @@ const bodySchema = z.strictObject({
autoProvision: z.boolean().optional(), autoProvision: z.boolean().optional(),
defaultRoleMapping: z.string().optional(), defaultRoleMapping: z.string().optional(),
defaultOrgMapping: 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 = { export type UpdateIdpResponse = {
@@ -96,7 +97,8 @@ export async function updateOidcIdp(
autoProvision, autoProvision,
defaultRoleMapping, defaultRoleMapping,
defaultOrgMapping, defaultOrgMapping,
tags tags,
variant
} = parsedBody.data; } = parsedBody.data;
if (process.env.IDENTITY_PROVIDER_MODE === "org") { if (process.env.IDENTITY_PROVIDER_MODE === "org") {
@@ -159,7 +161,8 @@ export async function updateOidcIdp(
scopes, scopes,
identifierPath, identifierPath,
emailPath, emailPath,
namePath namePath,
variant
}; };
keysToUpdate = Object.keys(configData).filter( keysToUpdate = Object.keys(configData).filter(

View File

@@ -13,6 +13,7 @@ import {
orgs, orgs,
Role, Role,
roles, roles,
userOrgRoles,
userOrgs, userOrgs,
users users
} from "@server/db"; } from "@server/db";
@@ -35,11 +36,13 @@ import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build"; import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { import {
assignUserToOrg, assignUserToOrg,
removeUserFromOrg removeUserFromOrg
} from "@server/lib/userOrg"; } from "@server/lib/userOrg";
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
const ensureTrailingSlash = (url: string): string => { const ensureTrailingSlash = (url: string): string => {
return url; return url;
@@ -366,7 +369,7 @@ export async function validateOidcCallback(
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping; const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping; const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
const userOrgInfo: { orgId: string; roleId: number }[] = []; const userOrgInfo: { orgId: string; roleIds: number[] }[] = [];
for (const org of allOrgs) { for (const org of allOrgs) {
const [idpOrgRes] = await db const [idpOrgRes] = await db
.select() .select()
@@ -378,8 +381,6 @@ export async function validateOidcCallback(
) )
); );
let roleId: number | undefined = undefined;
const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
const hydratedOrgMapping = hydrateOrgMapping( const hydratedOrgMapping = hydrateOrgMapping(
orgMapping, orgMapping,
@@ -404,38 +405,55 @@ export async function validateOidcCallback(
idpOrgRes?.roleMapping || defaultRoleMapping; idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) { if (roleMapping) {
logger.debug("Role Mapping", { 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) { const supportsMultiRole = await isLicensedOrSubscribed(
logger.error("Role name not found in the ID token", { org.orgId,
roleName tierMatrix.fullRbac
);
const effectiveRoleNames = supportsMultiRole
? roleNames
: roleNames.slice(0, 1);
if (!effectiveRoleNames.length) {
logger.error("Role mapping returned no valid roles", {
roleMappingResult
}); });
continue; continue;
} }
const [roleRes] = await db const roleRes = await db
.select() .select()
.from(roles) .from(roles)
.where( .where(
and( and(
eq(roles.orgId, org.orgId), eq(roles.orgId, org.orgId),
eq(roles.name, roleName) inArray(roles.name, effectiveRoleNames)
) )
); );
if (!roleRes) { if (!roleRes.length) {
logger.error("Role not found", { logger.error("No mapped roles found in organization", {
orgId: org.orgId, orgId: org.orgId,
roleName roleNames: effectiveRoleNames
}); });
continue; continue;
} }
roleId = roleRes.roleId; const roleIds = [...new Set(roleRes.map((r) => r.roleId))];
userOrgInfo.push({ userOrgInfo.push({
orgId: org.orgId, 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 // Sync roles 1:1 with IdP policy for existing auto-provisioned orgs
const orgsToUpdate = autoProvisionedOrgs.filter( for (const currentOrg of autoProvisionedOrgs) {
(currentOrg) => { const newRole = userOrgInfo.find(
const newOrg = userOrgInfo.find( (newOrg) => newOrg.orgId === currentOrg.orgId
(newOrg) => newOrg.orgId === currentOrg.orgId );
); if (!newRole) continue;
return newOrg && newOrg.roleId !== currentOrg.roleId;
}
);
if (orgsToUpdate.length > 0) { await trx
for (const org of orgsToUpdate) { .delete(userOrgRoles)
const newRole = userOrgInfo.find( .where(
(newOrg) => newOrg.orgId === org.orgId and(
eq(userOrgRoles.userId, userId!),
eq(userOrgRoles.orgId, currentOrg.orgId)
)
); );
if (newRole) {
await trx for (const roleId of newRole.roleIds) {
.update(userOrgs) await trx.insert(userOrgRoles).values({
.set({ roleId: newRole.roleId }) userId: userId!,
.where( orgId: currentOrg.orgId,
and( roleId
eq(userOrgs.userId, userId!), });
eq(userOrgs.orgId, org.orgId)
)
);
}
} }
} }
@@ -609,6 +623,10 @@ export async function validateOidcCallback(
if (orgsToAdd.length > 0) { if (orgsToAdd.length > 0) {
for (const org of orgsToAdd) { for (const org of orgsToAdd) {
if (org.roleIds.length === 0) {
continue;
}
const [fullOrg] = await trx const [fullOrg] = await trx
.select() .select()
.from(orgs) .from(orgs)
@@ -619,9 +637,9 @@ export async function validateOidcCallback(
{ {
orgId: org.orgId, orgId: org.orgId,
userId: userId!, userId: userId!,
roleId: org.roleId,
autoProvisioned: true, autoProvisioned: true,
}, },
org.roleIds,
trx trx
); );
} }
@@ -748,3 +766,25 @@ function hydrateOrgMapping(
} }
return orgMapping.split("{{orgId}}").join(orgId); 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 [];
}

View File

@@ -16,6 +16,7 @@ import {
verifyApiKey, verifyApiKey,
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
verifyApiKeyHasAction, verifyApiKeyHasAction,
verifyApiKeyCanSetUserOrgRoles,
verifyApiKeySiteAccess, verifyApiKeySiteAccess,
verifyApiKeyResourceAccess, verifyApiKeyResourceAccess,
verifyApiKeyTargetAccess, verifyApiKeyTargetAccess,
@@ -595,7 +596,7 @@ authenticated.post(
verifyLimits, verifyLimits,
verifyApiKeyHasAction(ActionsEnum.addUserRole), verifyApiKeyHasAction(ActionsEnum.addUserRole),
logActionAudit(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole),
user.addUserRole user.addUserRoleLegacy
); );
authenticated.post( authenticated.post(

View File

@@ -46,7 +46,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data; const { newtId, secret } = parsedBody.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );

View File

@@ -46,7 +46,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data; const { newtId, secret } = parsedBody.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, idp, idpOidcConfig } from "@server/db"; 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 { and, eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -14,7 +14,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy";
async function queryUser(orgId: string, userId: string) { async function queryUser(orgId: string, userId: string) {
const [user] = await db const [userRow] = await db
.select({ .select({
orgId: userOrgs.orgId, orgId: userOrgs.orgId,
userId: users.userId, userId: users.userId,
@@ -22,10 +22,7 @@ async function queryUser(orgId: string, userId: string) {
username: users.username, username: users.username,
name: users.name, name: users.name,
type: users.type, type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner, isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled, twoFactorEnabled: users.twoFactorEnabled,
autoProvisioned: userOrgs.autoProvisioned, autoProvisioned: userOrgs.autoProvisioned,
idpId: users.idpId, idpId: users.idpId,
@@ -35,13 +32,40 @@ async function queryUser(orgId: string, userId: string) {
idpAutoProvision: idp.autoProvision idpAutoProvision: idp.autoProvision
}) })
.from(userOrgs) .from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1); .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; export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult;

View File

@@ -9,6 +9,7 @@ import {
orgs, orgs,
roleActions, roleActions,
roles, roles,
userOrgRoles,
userOrgs, userOrgs,
users, users,
actions actions
@@ -312,9 +313,13 @@ export async function createOrg(
await trx.insert(userOrgs).values({ await trx.insert(userOrgs).values({
userId: req.user!.userId, userId: req.user!.userId,
orgId: newOrg[0].orgId, orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true isOwner: true
}); });
await trx.insert(userOrgRoles).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,
roleId
});
ownerUserId = req.user!.userId; ownerUserId = req.user!.userId;
} else { } else {
// if org created by root api key, set the server admin as the owner // 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({ await trx.insert(userOrgs).values({
userId: serverAdmin.userId, userId: serverAdmin.userId,
orgId: newOrg[0].orgId, orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true isOwner: true
}); });
await trx.insert(userOrgRoles).values({
userId: serverAdmin.userId,
orgId: newOrg[0].orgId,
roleId
});
ownerUserId = serverAdmin.userId; ownerUserId = serverAdmin.userId;
} }

View File

@@ -117,20 +117,26 @@ export async function getOrgOverview(
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.orgId, orgId)); .where(eq(userOrgs.orgId, orgId));
const [role] = await db const roleIds = req.userOrgRoleIds ?? [];
.select() const roleRows =
.from(roles) roleIds.length > 0
.where(eq(roles.roleId, req.userOrg.roleId)); ? 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<GetOrgOverviewResponse>(res, { return response<GetOrgOverviewResponse>(res, {
data: { data: {
orgName: org[0].name, orgName: org[0].name,
orgId: org[0].orgId, orgId: org[0].orgId,
userRoleName: role.name, userRoleName,
numSites, numSites,
numUsers, numUsers,
numResources, numResources,
isAdmin: role.isAdmin || false, isAdmin,
isOwner: req.userOrg?.isOwner || false isOwner: req.userOrg?.isOwner || false
}, },
success: true, success: true,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, roles } from "@server/db"; 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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -82,10 +82,7 @@ export async function listUserOrgs(
const { userId } = parsedParams.data; const { userId } = parsedParams.data;
const userOrganizations = await db const userOrganizations = await db
.select({ .select({ orgId: userOrgs.orgId })
orgId: userOrgs.orgId,
roleId: userOrgs.roleId
})
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.userId, userId)); .where(eq(userOrgs.userId, userId));
@@ -116,10 +113,27 @@ export async function listUserOrgs(
userOrgs, userOrgs,
and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId)) and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId))
) )
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.limit(limit) .limit(limit)
.offset(offset); .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 const totalCountResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` }) .select({ count: sql<number>`cast(count(*) as integer)` })
.from(orgs) .from(orgs)
@@ -133,8 +147,8 @@ export async function listUserOrgs(
if (val.userOrgs && val.userOrgs.isOwner) { if (val.userOrgs && val.userOrgs.isOwner) {
res.isOwner = val.userOrgs.isOwner; res.isOwner = val.userOrgs.isOwner;
} }
if (val.roles && val.roles.isAdmin) { if (val.orgs && orgHasAdmin.has(val.orgs.orgId)) {
res.isAdmin = val.roles.isAdmin; res.isAdmin = true;
} }
if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) { if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) {
res.isPrimaryOrg = val.orgs.isBillingOrg; res.isPrimaryOrg = val.orgs.isBillingOrg;

View File

@@ -112,7 +112,7 @@ export async function createResource(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );
@@ -292,7 +292,7 @@ async function createHttpResource(
resourceId: newResource[0].resourceId 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 // make sure the user can access the resource
await trx.insert(userResources).values({ await trx.insert(userResources).values({
userId: req.user?.userId!, userId: req.user?.userId!,
@@ -385,7 +385,7 @@ async function createRawResource(
resourceId: newResource[0].resourceId 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 // make sure the user can access the resource
await trx.insert(userResources).values({ await trx.insert(userResources).values({
userId: req.user?.userId!, userId: req.user?.userId!,

View File

@@ -5,6 +5,7 @@ import {
resources, resources,
userResources, userResources,
roleResources, roleResources,
userOrgRoles,
userOrgs, userOrgs,
resourcePassword, resourcePassword,
resourcePincode, resourcePincode,
@@ -32,22 +33,29 @@ export async function getUserResources(
); );
} }
// First get the user's role in the organization // Check user is in organization and get their role IDs
const userOrgResult = await db const [userOrg] = await db
.select({ .select()
roleId: userOrgs.roleId
})
.from(userOrgs) .from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1); .limit(1);
if (userOrgResult.length === 0) { if (!userOrg) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User not in organization") 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 // Get resources accessible through direct assignment or role assignment
const directResourcesQuery = db const directResourcesQuery = db
@@ -55,20 +63,28 @@ export async function getUserResources(
.from(userResources) .from(userResources)
.where(eq(userResources.userId, userId)); .where(eq(userResources.userId, userId));
const roleResourcesQuery = db const roleResourcesQuery =
.select({ resourceId: roleResources.resourceId }) userRoleIds.length > 0
.from(roleResources) ? db
.where(eq(roleResources.roleId, userRoleId)); .select({ resourceId: roleResources.resourceId })
.from(roleResources)
.where(inArray(roleResources.roleId, userRoleIds))
: Promise.resolve([]);
const directSiteResourcesQuery = db const directSiteResourcesQuery = db
.select({ siteResourceId: userSiteResources.siteResourceId }) .select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources) .from(userSiteResources)
.where(eq(userSiteResources.userId, userId)); .where(eq(userSiteResources.userId, userId));
const roleSiteResourcesQuery = db const roleSiteResourcesQuery =
.select({ siteResourceId: roleSiteResources.siteResourceId }) userRoleIds.length > 0
.from(roleSiteResources) ? db
.where(eq(roleSiteResources.roleId, userRoleId)); .select({
siteResourceId: roleSiteResources.siteResourceId
})
.from(roleSiteResources)
.where(inArray(roleSiteResources.roleId, userRoleIds))
: Promise.resolve([]);
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
directResourcesQuery, directResourcesQuery,

View File

@@ -305,7 +305,7 @@ export async function listResources(
.where( .where(
or( or(
eq(userResources.userId, req.user!.userId), eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!) inArray(roleResources.roleId, req.userOrgRoleIds!)
) )
); );
} else { } else {

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { roles, userOrgs } from "@server/db"; import { roles, userOrgRoles } from "@server/db";
import { eq } from "drizzle-orm"; import { and, eq, exists, aliasedTable } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -114,13 +114,32 @@ export async function deleteRole(
} }
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// move all users from the userOrgs table with roleId to newRoleId const uorNewRole = aliasedTable(userOrgRoles, "user_org_roles_new");
await trx
.update(userOrgs) // Users who already have newRoleId: drop the old assignment only (unique on userId+orgId+roleId).
.set({ roleId: newRoleId }) await trx.delete(userOrgRoles).where(
.where(eq(userOrgs.roleId, roleId)); 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)); await trx.delete(roles).where(eq(roles.roleId, roleId));
}); });

View File

@@ -111,7 +111,7 @@ export async function createSite(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) { if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next( return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role") createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
); );
@@ -399,7 +399,7 @@ export async function createSite(
siteId: newSite.siteId 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 // make sure the user can access the site
trx.insert(userSites).values({ trx.insert(userSites).values({
userId: req.user?.userId!, userId: req.user?.userId!,

View File

@@ -235,7 +235,7 @@ export async function listSites(
.where( .where(
or( or(
eq(userSites.userId, req.user!.userId), eq(userSites.userId, req.user!.userId),
eq(roleSites.roleId, req.userOrgRoleId!) inArray(roleSites.roleId, req.userOrgRoleIds!)
) )
); );
} else { } else {

View File

@@ -30,12 +30,15 @@ export async function traefikConfigProvider(
traefikConfig.http.middlewares[badgerMiddlewareName] = { traefikConfig.http.middlewares[badgerMiddlewareName] = {
plugin: { plugin: {
[badgerMiddlewareName]: { [badgerMiddlewareName]: {
apiBaseUrl: new URL( apiBaseUrl:
"/api/v1", config.getRawConfig().server.badger_override ||
`http://${ new URL(
config.getRawConfig().server.internal_hostname "/api/v1",
}:${config.getRawConfig().server.internal_port}` `http://${
).href, config.getRawConfig().server
.internal_hostname
}:${config.getRawConfig().server.internal_port}`
).href,
userSessionCookieName: userSessionCookieName:
config.getRawConfig().server.session_cookie_name, config.getRawConfig().server.session_cookie_name,
@@ -61,7 +64,7 @@ export async function traefikConfigProvider(
return res.status(HttpCode.OK).json(traefikConfig); return res.status(HttpCode.OK).json(traefikConfig);
} catch (e) { } catch (e) {
logger.error(`Failed to build Traefik config: ${e}`); logger.error(e);
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: "Failed to build Traefik config" error: "Failed to build Traefik config"
}); });

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, orgs, UserOrg } from "@server/db"; import { db, orgs } from "@server/db";
import { roles, userInvites, userOrgs, users } from "@server/db"; import { roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
import { eq, and, inArray, ne } from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -141,17 +141,34 @@ export async function acceptInvite(
); );
} }
let roleId: number; const inviteRoleRows = await db
// get the role to make sure it exists .select({ roleId: userInviteRoles.roleId })
const existingRole = await db .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() .select()
.from(roles) .from(roles)
.where(eq(roles.roleId, existingInvite.roleId)) .where(
.limit(1); and(
if (existingRole.length) { eq(roles.orgId, existingInvite.orgId),
roleId = existingRole[0].roleId; inArray(roles.roleId, inviteRoleIds)
} else { )
// TODO: use the default role on the org instead of failing );
if (existingRoles.length !== inviteRoleIds.length) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
@@ -165,9 +182,9 @@ export async function acceptInvite(
org, org,
{ {
userId: existingUser[0].userId, userId: existingUser[0].userId,
orgId: existingInvite.orgId, orgId: existingInvite.orgId
roleId: existingInvite.roleId
}, },
inviteRoleIds,
trx trx
); );

View File

@@ -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<any> {
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")
);
}
}

View File

@@ -6,8 +6,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { db, orgs, UserOrg } from "@server/db"; import { db, orgs } from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
import { generateId } from "@server/auth/sessions/app"; import { generateId } from "@server/auth/sessions/app";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
@@ -15,21 +15,43 @@ import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build"; import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; 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 { assignUserToOrg } from "@server/lib/userOrg";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
}); });
const bodySchema = z.strictObject({ const bodySchema = z
email: z.string().email().toLowerCase().optional(), .strictObject({
username: z.string().nonempty().toLowerCase(), email: z.string().email().toLowerCase().optional(),
name: z.string().optional(), username: z.string().nonempty().toLowerCase(),
type: z.enum(["internal", "oidc"]).optional(), name: z.string().optional(),
idpId: z.number().optional(), type: z.enum(["internal", "oidc"]).optional(),
roleId: z.number() 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 = {}; export type CreateOrgUserResponse = {};
@@ -78,7 +100,8 @@ export async function createOrgUser(
} }
const { orgId } = parsedParams.data; 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") { if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.USERS); 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") { if (type === "internal") {
return next( return next(
createHttpError( 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 const [org] = await db
.select() .select()
.from(orgs) .from(orgs)
@@ -221,12 +265,16 @@ export async function createOrgUser(
); );
} }
await assignUserToOrg(org, { await assignUserToOrg(
orgId, org,
userId: existingUser.userId, {
roleId: role.roleId, orgId,
autoProvisioned: false userId: existingUser.userId,
}, trx); autoProvisioned: false,
},
uniqueRoleIds,
trx
);
} else { } else {
userId = generateId(15); userId = generateId(15);
@@ -244,12 +292,16 @@ export async function createOrgUser(
}) })
.returning(); .returning();
await assignUserToOrg(org, { await assignUserToOrg(
orgId, org,
userId: newUser.userId, {
roleId: role.roleId, orgId,
autoProvisioned: false userId: newUser.userId,
}, trx); autoProvisioned: false,
},
uniqueRoleIds,
trx
);
} }
await calculateUserClientsForOrgs(userId, trx); await calculateUserClientsForOrgs(userId, trx);

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, idp, idpOidcConfig } from "@server/db"; 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 { and, eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -12,7 +12,7 @@ import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
export async function queryUser(orgId: string, userId: string) { export async function queryUser(orgId: string, userId: string) {
const [user] = await db const [userRow] = await db
.select({ .select({
orgId: userOrgs.orgId, orgId: userOrgs.orgId,
userId: users.userId, userId: users.userId,
@@ -20,10 +20,7 @@ export async function queryUser(orgId: string, userId: string) {
username: users.username, username: users.username,
name: users.name, name: users.name,
type: users.type, type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner, isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled, twoFactorEnabled: users.twoFactorEnabled,
autoProvisioned: userOrgs.autoProvisioned, autoProvisioned: userOrgs.autoProvisioned,
idpId: users.idpId, idpId: users.idpId,
@@ -33,13 +30,40 @@ export async function queryUser(orgId: string, userId: string) {
idpAutoProvision: idp.autoProvision idpAutoProvision: idp.autoProvision
}) })
.from(userOrgs) .from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1); .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< export type GetOrgUserResponse = NonNullable<

View File

@@ -1,7 +1,8 @@
export * from "./getUser"; export * from "./getUser";
export * from "./removeUserOrg"; export * from "./removeUserOrg";
export * from "./listUsers"; export * from "./listUsers";
export * from "./addUserRole"; export * from "./types";
export * from "./addUserRoleLegacy";
export * from "./inviteUser"; export * from "./inviteUser";
export * from "./acceptInvite"; export * from "./acceptInvite";
export * from "./getOrgUser"; export * from "./getOrgUser";

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { orgs, roles, userInvites, userOrgs, users } from "@server/db"; import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -18,22 +18,44 @@ import { OpenAPITags, registry } from "@server/openApi";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { FeatureId } from "@server/lib/billing";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build"; import { build } from "@server/build";
import cache from "#dynamic/lib/cache"; import cache from "#dynamic/lib/cache";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const inviteUserParamsSchema = z.strictObject({ const inviteUserParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
const inviteUserBodySchema = z.strictObject({ const inviteUserBodySchema = z
email: z.email().toLowerCase(), .strictObject({
roleId: z.number(), email: z.email().toLowerCase(),
validHours: z.number().gt(0).lte(168), roleIds: z.array(z.number().int().positive()).min(1).optional(),
sendEmail: z.boolean().optional(), roleId: z.number().int().positive().optional(),
regenerate: z.boolean().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<typeof inviteUserBodySchema>; export type InviteUserBody = z.input<typeof inviteUserBodySchema>;
export type InviteUserResponse = { export type InviteUserResponse = {
inviteLink: string; inviteLink: string;
@@ -88,7 +110,7 @@ export async function inviteUser(
const { const {
email, email,
validHours, validHours,
roleId, roleIds: uniqueRoleIds,
sendEmail: doEmail, sendEmail: doEmail,
regenerate regenerate
} = parsedBody.data; } = parsedBody.data;
@@ -105,14 +127,30 @@ export async function inviteUser(
); );
} }
// Validate that the roleId belongs to the target organization const supportsMultiRole = await isLicensedOrSubscribed(
const [role] = await db orgId,
.select() tierMatrix[TierFeature.FullRbac]
.from(roles) );
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId))) if (!supportsMultiRole && uniqueRoleIds.length > 1) {
.limit(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( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
@@ -191,7 +229,8 @@ export async function inviteUser(
} }
if (existingInvite.length) { if (existingInvite.length) {
const attempts = (await cache.get<number>(email)) || 0; const attempts =
(await cache.get<number>("regenerateInvite:" + email)) || 0;
if (attempts >= 3) { if (attempts >= 3) {
return next( return next(
createHttpError( createHttpError(
@@ -273,9 +312,11 @@ export async function inviteUser(
orgId, orgId,
email, email,
expiresAt, expiresAt,
tokenHash, tokenHash
roleId
}); });
await trx.insert(userInviteRoles).values(
uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))
);
}); });
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;

View File

@@ -1,11 +1,11 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; 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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { sql } from "drizzle-orm"; import { sql, eq, and, inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -29,24 +29,66 @@ const listInvitationsQuerySchema = z.strictObject({
.pipe(z.int().nonnegative()) .pipe(z.int().nonnegative())
}); });
async function queryInvitations(orgId: string, limit: number, offset: number) { export type InvitationListRow = {
return await db inviteId: string;
email: string;
expiresAt: number;
roles: { roleId: number; roleName: string | null }[];
};
async function queryInvitations(
orgId: string,
limit: number,
offset: number
): Promise<InvitationListRow[]> {
const inviteRows = await db
.select({ .select({
inviteId: userInvites.inviteId, inviteId: userInvites.inviteId,
email: userInvites.email, email: userInvites.email,
expiresAt: userInvites.expiresAt, expiresAt: userInvites.expiresAt
roleId: userInvites.roleId,
roleName: roles.name
}) })
.from(userInvites) .from(userInvites)
.leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`) .where(eq(userInvites.orgId, orgId))
.where(sql`${userInvites.orgId} = ${orgId}`)
.limit(limit) .limit(limit)
.offset(offset); .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 = { export type ListInvitationsResponse = {
invitations: NonNullable<Awaited<ReturnType<typeof queryInvitations>>>; invitations: InvitationListRow[];
pagination: { total: number; limit: number; offset: number }; pagination: { total: number; limit: number; offset: number };
}; };
@@ -95,7 +137,7 @@ export async function listInvitations(
const [{ count }] = await db const [{ count }] = await db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
.from(userInvites) .from(userInvites)
.where(sql`${userInvites.orgId} = ${orgId}`); .where(eq(userInvites.orgId, orgId));
return response<ListInvitationsResponse>(res, { return response<ListInvitationsResponse>(res, {
data: { data: {

View File

@@ -1,15 +1,14 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, idpOidcConfig } from "@server/db"; 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 response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; 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 logger from "@server/logger";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const listUsersParamsSchema = z.strictObject({ const listUsersParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -31,7 +30,7 @@ const listUsersSchema = z.strictObject({
}); });
async function queryUsers(orgId: string, limit: number, offset: number) { async function queryUsers(orgId: string, limit: number, offset: number) {
return await db const rows = await db
.select({ .select({
id: users.userId, id: users.userId,
email: users.email, email: users.email,
@@ -41,8 +40,6 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
username: users.username, username: users.username,
name: users.name, name: users.name,
type: users.type, type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner, isOwner: userOrgs.isOwner,
idpName: idp.name, idpName: idp.name,
idpId: users.idpId, idpId: users.idpId,
@@ -52,12 +49,48 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
}) })
.from(users) .from(users)
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(eq(userOrgs.orgId, orgId)) .where(eq(userOrgs.orgId, orgId))
.limit(limit) .limit(limit)
.offset(offset); .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 = { export type ListUsersResponse = {

View File

@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express"; 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 { idp, users } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -84,16 +84,31 @@ export async function myDevice(
.from(olms) .from(olms)
.where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId)));
const userOrganizations = await db const userOrgRows = await db
.select({ .select({
orgId: userOrgs.orgId, orgId: userOrgs.orgId,
orgName: orgs.name, orgName: orgs.name
roleId: userOrgs.roleId
}) })
.from(userOrgs) .from(userOrgs)
.where(eq(userOrgs.userId, userId)) .where(eq(userOrgs.userId, userId))
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)); .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<MyDeviceResponse>(res, { return response<MyDeviceResponse>(res, {
data: { data: {
user, user,

View File

@@ -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[];
};

View File

@@ -21,6 +21,7 @@ import m12 from "./scriptsPg/1.15.0";
import m13 from "./scriptsPg/1.15.3"; import m13 from "./scriptsPg/1.15.3";
import m14 from "./scriptsPg/1.15.4"; import m14 from "./scriptsPg/1.15.4";
import m15 from "./scriptsPg/1.16.0"; import m15 from "./scriptsPg/1.16.0";
import m16 from "./scriptsPg/1.17.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -41,7 +42,8 @@ const migrations = [
{ version: "1.15.0", run: m12 }, { version: "1.15.0", run: m12 },
{ version: "1.15.3", run: m13 }, { version: "1.15.3", run: m13 },
{ version: "1.15.4", run: m14 }, { 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 // Add new migrations here as they are created
] as { ] as {
version: string; version: string;

View File

@@ -39,6 +39,7 @@ import m33 from "./scriptsSqlite/1.15.0";
import m34 from "./scriptsSqlite/1.15.3"; import m34 from "./scriptsSqlite/1.15.3";
import m35 from "./scriptsSqlite/1.15.4"; import m35 from "./scriptsSqlite/1.15.4";
import m36 from "./scriptsSqlite/1.16.0"; import m36 from "./scriptsSqlite/1.16.0";
import m37 from "./scriptsSqlite/1.17.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -75,7 +76,8 @@ const migrations = [
{ version: "1.15.0", run: m33 }, { version: "1.15.0", run: m33 },
{ version: "1.15.3", run: m34 }, { version: "1.15.3", run: m34 },
{ version: "1.15.4", run: m35 }, { 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 // Add new migrations here as they are created
] as const; ] as const;

View File

@@ -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`);
}

View File

@@ -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`);
}

View File

@@ -5,5 +5,5 @@ import { Session } from "@server/db";
export interface AuthenticatedRequest extends Request { export interface AuthenticatedRequest extends Request {
user: User; user: User;
session: Session; session: Session;
userOrgRoleId?: number; userOrgRoleIds?: number[];
} }

View File

@@ -45,8 +45,17 @@ import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
compileRoleMappingExpression,
createMappingBuilderRule,
detectRoleMappingConfig,
ensureMappingBuilderRuleIds,
MappingBuilderRule,
RoleMappingMode
} from "@app/lib/idpRoleMapping";
export default function GeneralPage() { export default function GeneralPage() {
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -56,9 +65,15 @@ export default function GeneralPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true); const [initialLoading, setInitialLoading] = useState(true);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [roleMappingMode, setRoleMappingMode] = useState< const [roleMappingMode, setRoleMappingMode] =
"role" | "expression" useState<RoleMappingMode>("fixedRoles");
>("role"); const [fixedRoleNames, setFixedRoleNames] = useState<string[]>([]);
const [mappingBuilderClaimPath, setMappingBuilderClaimPath] =
useState("groups");
const [mappingBuilderRules, setMappingBuilderRules] = useState<
MappingBuilderRule[]
>([createMappingBuilderRule()]);
const [rawRoleExpression, setRawRoleExpression] = useState("");
const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc");
const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
@@ -190,34 +205,8 @@ export default function GeneralPage() {
// Set the variant // Set the variant
setVariant(idpVariant as "oidc" | "google" | "azure"); setVariant(idpVariant as "oidc" | "google" | "azure");
// Check if roleMapping matches the basic pattern '{role name}' (simple single role) const detectedRoleMappingConfig =
// This should NOT match complex expressions like 'Admin' || 'Member' detectRoleMappingConfig(roleMapping);
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;
}
}
// Extract tenant ID from Azure URLs if present // Extract tenant ID from Azure URLs if present
let tenantId = ""; let tenantId = "";
@@ -238,9 +227,7 @@ export default function GeneralPage() {
clientSecret: data.idpOidcConfig.clientSecret, clientSecret: data.idpOidcConfig.clientSecret,
autoProvision: data.idp.autoProvision, autoProvision: data.idp.autoProvision,
roleMapping: roleMapping || null, roleMapping: roleMapping || null,
roleId: isRoleId roleId: null
? Number(roleMapping)
: matchingRoleId || null
}; };
// Add variant-specific fields // Add variant-specific fields
@@ -259,10 +246,18 @@ export default function GeneralPage() {
form.reset(formData); form.reset(formData);
// Set the role mapping mode based on the data setRoleMappingMode(detectedRoleMappingConfig.mode);
// Default to "expression" unless it's a simple roleId or basic '{role name}' pattern setFixedRoleNames(detectedRoleMappingConfig.fixedRoleNames);
setRoleMappingMode( setMappingBuilderClaimPath(
matchingRoleId && isRoleName ? "role" : "expression" detectedRoleMappingConfig.mappingBuilder.claimPath
);
setMappingBuilderRules(
ensureMappingBuilderRuleIds(
detectedRoleMappingConfig.mappingBuilder.rules
)
);
setRawRoleExpression(
detectedRoleMappingConfig.rawExpression
); );
} }
} catch (e) { } catch (e) {
@@ -327,7 +322,26 @@ export default function GeneralPage() {
return; 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 // Build payload based on variant
let payload: any = { let payload: any = {
@@ -335,10 +349,7 @@ export default function GeneralPage() {
clientId: data.clientId, clientId: data.clientId,
clientSecret: data.clientSecret, clientSecret: data.clientSecret,
autoProvision: data.autoProvision, autoProvision: data.autoProvision,
roleMapping: roleMapping: roleMappingExpression
roleMappingMode === "role"
? `'${roleName}'`
: data.roleMapping || ""
}; };
// Add variant-specific fields // Add variant-specific fields
@@ -438,16 +449,6 @@ export default function GeneralPage() {
</InfoSection> </InfoSection>
</InfoSections> </InfoSections>
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("redirectUrlAbout")}
</AlertTitle>
<AlertDescription>
{t("redirectUrlAboutDescription")}
</AlertDescription>
</Alert>
{/* IDP Type Indicator */} {/* IDP Type Indicator */}
<div className="flex items-center space-x-2 mb-4"> <div className="flex items-center space-x-2 mb-4">
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
@@ -493,46 +494,47 @@ export default function GeneralPage() {
{t("idpAutoProvisionUsers")} {t("idpAutoProvisionUsers")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t("idpAutoProvisionUsersDescription")} <IdpAutoProvisionUsersDescription />
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <PaidFeaturesAlert
<PaidFeaturesAlert tiers={tierMatrix.autoProvisioning}
tiers={tierMatrix.autoProvisioning} />
/>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4" className="space-y-4"
id="general-settings-form" id="general-settings-form"
> >
<AutoProvisionConfigWidget <AutoProvisionConfigWidget
control={form.control} autoProvision={form.watch("autoProvision")}
autoProvision={form.watch( onAutoProvisionChange={(checked) => {
"autoProvision" form.setValue("autoProvision", checked);
)} }}
onAutoProvisionChange={(checked) => { roleMappingMode={roleMappingMode}
form.setValue( onRoleMappingModeChange={(data) => {
"autoProvision", setRoleMappingMode(data);
checked }}
); roles={roles}
}} fixedRoleNames={fixedRoleNames}
roleMappingMode={roleMappingMode} onFixedRoleNamesChange={setFixedRoleNames}
onRoleMappingModeChange={(data) => { mappingBuilderClaimPath={
setRoleMappingMode(data); mappingBuilderClaimPath
// Clear roleId and roleMapping when mode changes }
form.setValue("roleId", null); onMappingBuilderClaimPathChange={
form.setValue("roleMapping", null); setMappingBuilderClaimPath
}} }
roles={roles} mappingBuilderRules={mappingBuilderRules}
roleIdFieldName="roleId" onMappingBuilderRulesChange={
roleMappingFieldName="roleMapping" setMappingBuilderRules
/> }
</form> rawExpression={rawRoleExpression}
</Form> onRawExpressionChange={setRawRoleExpression}
</SettingsSectionForm> />
</form>
</Form>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
@@ -832,29 +834,6 @@ export default function GeneralPage() {
className="space-y-4" className="space-y-4"
id="general-settings-form" id="general-settings-form"
> >
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("idpJmespathAbout")}
</AlertTitle>
<AlertDescription>
{t(
"idpJmespathAboutDescription"
)}{" "}
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
{t(
"idpJmespathAboutDescriptionLink"
)}{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField <FormField
control={form.control} control={form.control}
name="identifierPath" name="identifierPath"

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { import {
SettingsContainer, SettingsContainer,
@@ -13,7 +14,7 @@ import {
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { StrategySelect } from "@app/components/StrategySelect"; import { OidcIdpProviderTypeSelect } from "@app/components/idp/OidcIdpProviderTypeSelect";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
@@ -27,21 +28,26 @@ import {
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { applyOidcIdpProviderType } from "@app/lib/idp/oidcIdpProviderDefaults";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { InfoIcon } from "lucide-react"; import { InfoIcon } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Image from "next/image";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import {
compileRoleMappingExpression,
createMappingBuilderRule,
MappingBuilderRule,
RoleMappingMode
} from "@app/lib/idpRoleMapping";
export default function Page() { export default function Page() {
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -49,9 +55,15 @@ export default function Page() {
const router = useRouter(); const router = useRouter();
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [roleMappingMode, setRoleMappingMode] = useState< const [roleMappingMode, setRoleMappingMode] =
"role" | "expression" useState<RoleMappingMode>("fixedRoles");
>("role"); const [fixedRoleNames, setFixedRoleNames] = useState<string[]>([]);
const [mappingBuilderClaimPath, setMappingBuilderClaimPath] =
useState("groups");
const [mappingBuilderRules, setMappingBuilderRules] = useState<
MappingBuilderRule[]
>([createMappingBuilderRule()]);
const [rawRoleExpression, setRawRoleExpression] = useState("");
const t = useTranslations(); const t = useTranslations();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
@@ -84,49 +96,6 @@ export default function Page() {
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>; type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
interface ProviderTypeOption {
id: "oidc" | "google" | "azure";
title: string;
description: string;
icon?: React.ReactNode;
}
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
{
id: "oidc",
title: "OAuth2/OIDC",
description: t("idpOidcDescription")
},
{
id: "google",
title: t("idpGoogleTitle"),
description: t("idpGoogleDescription"),
icon: (
<Image
src="/idp/google.png"
alt={t("idpGoogleAlt")}
width={24}
height={24}
className="rounded"
/>
)
},
{
id: "azure",
title: t("idpAzureTitle"),
description: t("idpAzureDescription"),
icon: (
<Image
src="/idp/azure.png"
alt={t("idpAzureAlt")}
width={24}
height={24}
className="rounded"
/>
)
}
];
const form = useForm({ const form = useForm({
resolver: zodResolver(createIdpFormSchema), resolver: zodResolver(createIdpFormSchema),
defaultValues: { defaultValues: {
@@ -174,47 +143,6 @@ export default function Page() {
fetchRoles(); 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) { async function onSubmit(data: CreateIdpFormValues) {
setCreateLoading(true); setCreateLoading(true);
@@ -228,7 +156,26 @@ export default function Page() {
tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId); 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 = { const payload = {
name: data.name, name: data.name,
@@ -240,10 +187,7 @@ export default function Page() {
emailPath: data.emailPath, emailPath: data.emailPath,
namePath: data.namePath, namePath: data.namePath,
autoProvision: data.autoProvision, autoProvision: data.autoProvision,
roleMapping: roleMapping: roleMappingExpression,
roleMappingMode === "role"
? `'${roleName}'`
: data.roleMapping || "",
scopes: data.scopes, scopes: data.scopes,
variant: data.type variant: data.type
}; };
@@ -308,23 +252,12 @@ export default function Page() {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<div> <OidcIdpProviderTypeSelect
<div className="mb-2"> value={form.watch("type")}
<span className="text-sm font-medium"> onTypeChange={(next) => {
{t("idpType")} applyOidcIdpProviderType(form.setValue, next);
</span> }}
</div> />
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
handleProviderChange(
value as "oidc" | "google" | "azure"
);
}}
cols={3}
/>
</div>
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
@@ -364,47 +297,48 @@ export default function Page() {
{t("idpAutoProvisionUsers")} {t("idpAutoProvisionUsers")}
</SettingsSectionTitle> </SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{t("idpAutoProvisionUsersDescription")} <IdpAutoProvisionUsersDescription />
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm> <PaidFeaturesAlert
<PaidFeaturesAlert tiers={tierMatrix.autoProvisioning}
tiers={tierMatrix.autoProvisioning} />
/> <Form {...form}>
<Form {...form}> <form
<form className="space-y-4"
className="space-y-4" id="create-idp-form"
id="create-idp-form" onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit)} >
> <AutoProvisionConfigWidget
<AutoProvisionConfigWidget autoProvision={
control={form.control} form.watch("autoProvision") as boolean
autoProvision={ } // is this right?
form.watch( onAutoProvisionChange={(checked) => {
"autoProvision" form.setValue("autoProvision", checked);
) as boolean }}
} // is this right? roleMappingMode={roleMappingMode}
onAutoProvisionChange={(checked) => { onRoleMappingModeChange={(data) => {
form.setValue( setRoleMappingMode(data);
"autoProvision", }}
checked roles={roles}
); fixedRoleNames={fixedRoleNames}
}} onFixedRoleNamesChange={setFixedRoleNames}
roleMappingMode={roleMappingMode} mappingBuilderClaimPath={
onRoleMappingModeChange={(data) => { mappingBuilderClaimPath
setRoleMappingMode(data); }
// Clear roleId and roleMapping when mode changes onMappingBuilderClaimPathChange={
form.setValue("roleId", null); setMappingBuilderClaimPath
form.setValue("roleMapping", null); }
}} mappingBuilderRules={mappingBuilderRules}
roles={roles} onMappingBuilderRulesChange={
roleIdFieldName="roleId" setMappingBuilderRules
roleMappingFieldName="roleMapping" }
/> rawExpression={rawRoleExpression}
</form> onRawExpressionChange={setRawRoleExpression}
</Form> />
</SettingsSectionForm> </form>
</Form>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
@@ -679,16 +613,6 @@ export default function Page() {
/> />
</form> </form>
</Form> </Form>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("idpOidcConfigureAlert")}
</AlertTitle>
<AlertDescription>
{t("idpOidcConfigureAlertDescription")}
</AlertDescription>
</Alert>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -29,9 +29,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
let invitations: { let invitations: {
inviteId: string; inviteId: string;
email: string; email: string;
expiresAt: string; expiresAt: number;
roleId: number; roles: { roleId: number; roleName: string | null }[];
roleName?: string;
}[] = []; }[] = [];
let hasInvitations = false; let hasInvitations = false;
@@ -66,12 +65,15 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
} }
const invitationRows: InvitationRow[] = invitations.map((invite) => { const invitationRows: InvitationRow[] = invitations.map((invite) => {
const names = invite.roles
.map((r) => r.roleName || t("accessRoleUnknown"))
.filter(Boolean);
return { return {
id: invite.inviteId, id: invite.inviteId,
email: invite.email, email: invite.email,
expiresAt: new Date(Number(invite.expiresAt)).toISOString(), expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
role: invite.roleName || t("accessRoleUnknown"), roleLabels: names,
roleId: invite.roleId roleIds: invite.roles.map((r) => r.roleId)
}; };
}); });

Some files were not shown because too many files have changed in this diff Show More