Compare commits

...

23 Commits

Author SHA1 Message Date
Owen
a143b7de7c Merge branch 'multi-role' of github.com:fosrl/pangolin into multi-role 2026-03-26 21:47:13 -07:00
Owen
63372b174f Merge branch 'dev' into multi-role 2026-03-26 21:46:29 -07:00
miloschwartz
ad7d68d2b4 basic idp mapping builder 2026-03-26 21:46:01 -07:00
miloschwartz
13eadeaa8f support legacy one role per user 2026-03-26 18:19:10 -07:00
miloschwartz
d046084e84 delete role move to new role 2026-03-26 16:44:30 -07:00
miloschwartz
e13a076939 ui improvements 2026-03-26 16:37:31 -07:00
Owen
395cab795c Batch set bandwidth 2026-03-25 20:35:21 -07:00
miloschwartz
0fecbe704b Merge branch 'dev' into multi-role 2026-03-24 22:01:13 -07:00
Owen
ce59a8a52b Merge branch 'main' into dev 2026-03-24 20:38:16 -07:00
Owen
38d30b0214 Add license script 2026-03-24 18:13:57 -07:00
Owen
fff38aac85 Add ssh access log 2026-03-24 16:26:56 -07:00
Owen
5a2a97b23a Add better pooling controls 2026-03-24 16:12:13 -07:00
Owen
5b894e8682 Disable everything if not paid 2026-03-24 16:01:54 -07:00
Owen Schwartz
19f8c1772f Merge pull request #2698 from fosrl/msg-opt
Improve proxy list message size
2026-03-23 16:05:24 -07:00
Owen
37d331e813 Update version 2026-03-23 16:05:05 -07:00
Owen
c660df55cd Merge branch 'dev' into msg-opt 2026-03-23 16:00:50 -07:00
Owen Schwartz
7c8b865379 Merge pull request #2695 from noe-charmet/redis-password-env
Allow setting Redis password from env
2026-03-23 12:02:45 -07:00
Noe Charmet
3cca0c09c0 Allow setting Redis password from env 2026-03-23 11:18:55 +01:00
Owen
b01fcc70fe Fix ts and add note about ipv4 2026-03-03 14:45:18 -08:00
Owen
35fed74e49 Merge branch 'dev' into msg-opt 2026-03-02 18:52:35 -08:00
Owen
6cf1b9b010 Support improved targets msg v2 2026-03-02 18:51:48 -08:00
Owen
dae169540b Fix defaults for orgs 2026-03-02 16:49:17 -08:00
miloschwartz
20e547a0f6 first pass 2026-02-24 17:58:11 -08:00
105 changed files with 2917 additions and 786 deletions

115
license.py Normal file
View File

@@ -0,0 +1,115 @@
import os
import sys
# --- Configuration ---
# The header text to be added to the files.
HEADER_TEXT = """/*
* 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.
*/
"""
def should_add_header(file_path):
"""
Checks if a file should receive the commercial license header.
Returns True if 'private' is in the path or file content.
"""
# Check if 'private' is in the file path (case-insensitive)
if 'server/private' in file_path.lower():
return True
# Check if 'private' is in the file content (case-insensitive)
# try:
# with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
# content = f.read()
# if 'private' in content.lower():
# return True
# except Exception as e:
# print(f"Could not read file {file_path}: {e}")
return False
def process_directory(root_dir):
"""
Recursively scans a directory and adds headers to qualifying .ts or .tsx files,
skipping any 'node_modules' directories.
"""
print(f"Scanning directory: {root_dir}")
files_processed = 0
headers_added = 0
for root, dirs, files in os.walk(root_dir):
# --- MODIFICATION ---
# Exclude 'node_modules' directories from the scan to improve performance.
if 'node_modules' in dirs:
dirs.remove('node_modules')
for file in files:
if file.endswith('.ts') or file.endswith('.tsx'):
file_path = os.path.join(root, file)
files_processed += 1
try:
with open(file_path, 'r+', encoding='utf-8') as f:
original_content = f.read()
has_header = original_content.startswith(HEADER_TEXT.strip())
if should_add_header(file_path):
# Add header only if it's not already there
if not has_header:
f.seek(0, 0) # Go to the beginning of the file
f.write(HEADER_TEXT.strip() + '\n\n' + original_content)
print(f"Added header to: {file_path}")
headers_added += 1
else:
print(f"Header already exists in: {file_path}")
else:
# Remove header if it exists but shouldn't be there
if has_header:
# Find the end of the header and remove it (including following newlines)
header_with_newlines = HEADER_TEXT.strip() + '\n\n'
if original_content.startswith(header_with_newlines):
content_without_header = original_content[len(header_with_newlines):]
else:
# Handle case where there might be different newline patterns
header_end = len(HEADER_TEXT.strip())
# Skip any newlines after the header
while header_end < len(original_content) and original_content[header_end] in '\n\r':
header_end += 1
content_without_header = original_content[header_end:]
f.seek(0)
f.write(content_without_header)
f.truncate()
print(f"Removed header from: {file_path}")
headers_added += 1 # Reusing counter for modifications
except Exception as e:
print(f"Error processing file {file_path}: {e}")
print("\n--- Scan Complete ---")
print(f"Total .ts or .tsx files found: {files_processed}")
print(f"Files modified (headers added/removed): {headers_added}")
if __name__ == "__main__":
# Get the target directory from the command line arguments.
# If no directory is provided, it uses the current directory ('.').
if len(sys.argv) > 1:
target_directory = sys.argv[1]
else:
target_directory = '.' # Default to current directory
if not os.path.isdir(target_directory):
print(f"Error: Directory '{target_directory}' not found.")
sys.exit(1)
process_directory(os.path.abspath(target_directory))

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

@@ -512,6 +512,8 @@
"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",
@@ -1150,6 +1152,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",

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

@@ -9,6 +9,7 @@ import {
real, real,
serial, serial,
text, text,
unique,
varchar varchar
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
@@ -335,9 +336,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 +384,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()
@@ -1035,6 +1049,7 @@ 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 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

@@ -12,6 +12,7 @@ import {
resources, resources,
roleResources, roleResources,
sessions, sessions,
userOrgRoles,
userOrgs, userOrgs,
userResources, userResources,
users, users,
@@ -104,24 +105,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;
} }
/** /**

View File

@@ -1,6 +1,12 @@
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,
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 +649,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 +703,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()
@@ -1134,6 +1153,7 @@ 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 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

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

@@ -571,6 +571,129 @@ export function generateSubnetProxyTargets(
return targets; return targets;
} }
export type SubnetProxyTargetV2 = {
sourcePrefixes: string[]; // must be cidrs
destPrefix: string; // must be a cidr
disableIcmp?: boolean;
rewriteTo?: string; // must be a cidr
portRange?: {
min: number;
max: number;
protocol: "tcp" | "udp";
}[];
};
export function generateSubnetProxyTargetV2(
siteResource: SiteResource,
clients: {
clientId: number;
pubKey: string | null;
subnet: string | null;
}[]
): SubnetProxyTargetV2 | undefined {
if (clients.length === 0) {
logger.debug(
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
);
return;
}
let target: SubnetProxyTargetV2 | null = null;
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
...parsePortRangeString(siteResource.udpPortRangeString, "udp")
];
const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host") {
let destination = siteResource.destination;
// check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
if (ipSchema.safeParse(destination).success) {
destination = `${destination}/32`;
target = {
sourcePrefixes: [],
destPrefix: destination,
portRange,
disableIcmp
};
}
if (siteResource.alias && siteResource.aliasAddress) {
// also push a match for the alias address
target = {
sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
portRange,
disableIcmp
};
}
} else if (siteResource.mode == "cidr") {
target = {
sourcePrefixes: [],
destPrefix: siteResource.destination,
portRange,
disableIcmp
};
}
if (!target) {
return;
}
for (const clientSite of clients) {
if (!clientSite.subnet) {
logger.debug(
`Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.`
);
continue;
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
// add client prefix to source prefixes
target.sourcePrefixes.push(clientPrefix);
}
// print a nice representation of the targets
// logger.debug(
// `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}`
// );
return target;
}
/**
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
* by expanding each source prefix into its own target entry.
* @param targetV2 - The v2 target to convert
* @returns Array of v1 SubnetProxyTarget objects
*/
export function convertSubnetProxyTargetsV2ToV1(
targetsV2: SubnetProxyTargetV2[]
): SubnetProxyTarget[] {
return targetsV2.flatMap((targetV2) =>
targetV2.sourcePrefixes.map((sourcePrefix) => ({
sourcePrefix,
destPrefix: targetV2.destPrefix,
...(targetV2.disableIcmp !== undefined && {
disableIcmp: targetV2.disableIcmp
}),
...(targetV2.rewriteTo !== undefined && {
rewriteTo: targetV2.rewriteTo
}),
...(targetV2.portRange !== undefined && {
portRange: targetV2.portRange
})
}))
);
}
// Custom schema for validating port range strings // Custom schema for validating port range strings
// Format: "80,443,8000-9000" or "*" for all ports, or empty string // Format: "80,443,8000-9000" or "*" for all ports, or empty string
export const portRangeStringSchema = z export const portRangeStringSchema = z

View File

@@ -302,8 +302,8 @@ export const configSchema = z
.optional() .optional()
.default({ .default({
block_size: 24, block_size: 24,
subnet_group: "100.90.128.0/24", subnet_group: "100.90.128.0/20",
utility_subnet_group: "100.96.128.0/24" utility_subnet_group: "100.96.128.0/20"
}), }),
rate_limits: z rate_limits: z
.object({ .object({

View File

@@ -14,6 +14,7 @@ import {
siteResources, siteResources,
sites, sites,
Transaction, Transaction,
userOrgRoles,
userOrgs, userOrgs,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
@@ -32,7 +33,7 @@ import logger from "@server/logger";
import { import {
generateAliasConfig, generateAliasConfig,
generateRemoteSubnets, generateRemoteSubnets,
generateSubnetProxyTargets, generateSubnetProxyTargetV2,
parseEndpoint, parseEndpoint,
formatEndpoint formatEndpoint
} from "@server/lib/ip"; } from "@server/lib/ip";
@@ -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(
@@ -660,19 +661,16 @@ async function handleSubnetProxyTargetUpdates(
); );
if (addedClients.length > 0) { if (addedClients.length > 0) {
const targetsToAdd = generateSubnetProxyTargets( const targetToAdd = generateSubnetProxyTargetV2(
siteResource, siteResource,
addedClients addedClients
); );
if (targetsToAdd.length > 0) { if (targetToAdd) {
logger.info(
`Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
);
proxyJobs.push( proxyJobs.push(
addSubnetProxyTargets( addSubnetProxyTargets(
newt.newtId, newt.newtId,
targetsToAdd, [targetToAdd],
newt.version newt.version
) )
); );
@@ -700,19 +698,16 @@ async function handleSubnetProxyTargetUpdates(
); );
if (removedClients.length > 0) { if (removedClients.length > 0) {
const targetsToRemove = generateSubnetProxyTargets( const targetToRemove = generateSubnetProxyTargetV2(
siteResource, siteResource,
removedClients removedClients
); );
if (targetsToRemove.length > 0) { if (targetToRemove) {
logger.info(
`Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
);
proxyJobs.push( proxyJobs.push(
removeSubnetProxyTargets( removeSubnetProxyTargets(
newt.newtId, newt.newtId,
targetsToRemove, [targetToRemove],
newt.version newt.version
) )
); );
@@ -820,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));
@@ -1169,7 +1164,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const targets = generateSubnetProxyTargets(resource, [ const target = generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,
@@ -1177,11 +1172,11 @@ async function handleMessagesForClientResources(
} }
]); ]);
if (targets.length > 0) { if (target) {
proxyJobs.push( proxyJobs.push(
addSubnetProxyTargets( addSubnetProxyTargets(
newt.newtId, newt.newtId,
targets, [target],
newt.version newt.version
) )
); );
@@ -1246,7 +1241,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const targets = generateSubnetProxyTargets(resource, [ const target = generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,
@@ -1254,11 +1249,11 @@ async function handleMessagesForClientResources(
} }
]); ]);
if (targets.length > 0) { if (target) {
proxyJobs.push( proxyJobs.push(
removeSubnetProxyTargets( removeSubnetProxyTargets(
newt.newtId, newt.newtId,
targets, [target],
newt.version newt.version
) )
); );

View File

@@ -6,7 +6,7 @@ import {
siteResources, siteResources,
sites, sites,
Transaction, Transaction,
UserOrg, userOrgRoles,
userOrgs, userOrgs,
userResources, userResources,
userSiteResources, userSiteResources,
@@ -19,9 +19,15 @@ 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,
roleId: number,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
) { ) {
const [userOrg] = await trx.insert(userOrgs).values(values).returning(); const [userOrg] = await trx.insert(userOrgs).values(values).returning();
await trx.insert(userOrgRoles).values({
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 +64,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,22 @@
import { db, 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);
}

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

@@ -57,7 +57,10 @@ export const privateConfigSchema = z.object({
.object({ .object({
host: z.string(), host: z.string(),
port: portSchema, port: portSchema,
password: z.string().optional(), password: z
.string()
.optional()
.transform(getEnvOrYaml("REDIS_PASSWORD")),
db: z.int().nonnegative().optional().default(0), db: z.int().nonnegative().optional().default(0),
replicas: z replicas: z
.array( .array(

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,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

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

@@ -24,6 +24,7 @@ import {
sites, sites,
userOrgs userOrgs
} from "@server/db"; } from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -31,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";
@@ -125,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(
@@ -133,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)
@@ -339,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) {
@@ -351,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
@@ -463,6 +484,24 @@ export async function signSshKey(
}) })
}); });
await logAccessAudit({
action: true,
type: "ssh",
orgId: orgId,
resourceId: resource.siteResourceId,
user: req.user
? { username: req.user.username ?? "", userId: req.user.userId }
: undefined,
metadata: {
resourceName: resource.name,
siteId: resource.siteId,
sshUsername: usernameToUse,
sshHost: sshHost
},
userAgent: req.headers["user-agent"],
requestIp: req.ip
});
return response<SignSshKeyResponse>(res, { return response<SignSshKeyResponse>(res, {
data: { data: {
certificate: cert.certificate, certificate: cert.certificate,

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

@@ -3,12 +3,13 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
import { import {
getResourceByDomain, getResourceByDomain,
getResourceRules, getResourceRules,
getRoleName,
getRoleResourceAccess, getRoleResourceAccess,
getUserOrgRole,
getUserResourceAccess, getUserResourceAccess,
getOrgLoginPage, getOrgLoginPage,
getUserSessionWithUser getUserSessionWithUser
} from "@server/db/queries/verifySessionQueries"; } from "@server/db/queries/verifySessionQueries";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { import {
LoginPage, LoginPage,
Org, Org,
@@ -916,9 +917,9 @@ async function isUserAllowedToAccessResource(
return null; return null;
} }
const userOrgRole = await getUserOrgRole(user.userId, resource.orgId); const userOrgRoleIds = await getUserOrgRoleIds(user.userId, resource.orgId);
if (!userOrgRole) { if (!userOrgRoleIds.length) {
return null; return null;
} }
@@ -934,17 +935,23 @@ async function isUserAllowedToAccessResource(
return null; return null;
} }
const roleResourceAccess = await getRoleResourceAccess( const roleNames: string[] = [];
resource.resourceId, for (const roleId of userOrgRoleIds) {
userOrgRole.roleId const roleResourceAccess = await getRoleResourceAccess(
); resource.resourceId,
roleId
if (roleResourceAccess) { );
if (roleResourceAccess) {
const roleName = await getRoleName(roleId);
if (roleName) roleNames.push(roleName);
}
}
if (roleNames.length > 0) {
return { return {
username: user.username, username: user.username,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: userOrgRole.roleName role: roleNames.join(", ")
}; };
} }
@@ -954,11 +961,15 @@ async function isUserAllowedToAccessResource(
); );
if (userResourceAccess) { if (userResourceAccess) {
const names = await Promise.all(
userOrgRoleIds.map((id) => getRoleName(id))
);
const role = names.filter(Boolean).join(", ") || "";
return { return {
username: user.username, username: user.username,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: userOrgRole.roleName role
}; };
} }

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

@@ -1,15 +1,54 @@
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { db, olms, Transaction } from "@server/db"; import { db, newts, olms } from "@server/db";
import {
Alias,
convertSubnetProxyTargetsV2ToV1,
SubnetProxyTarget,
SubnetProxyTargetV2
} from "@server/lib/ip";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import { Alias, SubnetProxyTarget } from "@server/lib/ip";
import logger from "@server/logger"; import logger from "@server/logger";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import semver from "semver";
const NEWT_V2_TARGETS_VERSION = ">=1.10.3";
export async function convertTargetsIfNessicary(
newtId: string,
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]
) {
// get the newt
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.newtId, newtId));
if (!newt) {
throw new Error(`No newt found for id: ${newtId}`);
}
// check the semver
if (
newt.version &&
!semver.satisfies(newt.version, NEWT_V2_TARGETS_VERSION)
) {
logger.debug(
`addTargets Newt version ${newt.version} does not support targets v2 falling back`
);
targets = convertSubnetProxyTargetsV2ToV1(
targets as SubnetProxyTargetV2[]
);
}
return targets;
}
export async function addTargets( export async function addTargets(
newtId: string, newtId: string,
targets: SubnetProxyTarget[], targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
version?: string | null version?: string | null
) { ) {
targets = await convertTargetsIfNessicary(newtId, targets);
await sendToClient( await sendToClient(
newtId, newtId,
{ {
@@ -22,9 +61,11 @@ export async function addTargets(
export async function removeTargets( export async function removeTargets(
newtId: string, newtId: string,
targets: SubnetProxyTarget[], targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
version?: string | null version?: string | null
) { ) {
targets = await convertTargetsIfNessicary(newtId, targets);
await sendToClient( await sendToClient(
newtId, newtId,
{ {
@@ -38,11 +79,39 @@ export async function removeTargets(
export async function updateTargets( export async function updateTargets(
newtId: string, newtId: string,
targets: { targets: {
oldTargets: SubnetProxyTarget[]; oldTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
newTargets: SubnetProxyTarget[]; newTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
}, },
version?: string | null version?: string | null
) { ) {
// get the newt
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.newtId, newtId));
if (!newt) {
logger.error(`addTargetsL No newt found for id: ${newtId}`);
return;
}
// check the semver
if (
newt.version &&
!semver.satisfies(newt.version, NEWT_V2_TARGETS_VERSION)
) {
logger.debug(
`addTargets Newt version ${newt.version} does not support targets v2 falling back`
);
targets = {
oldTargets: convertSubnetProxyTargetsV2ToV1(
targets.oldTargets as SubnetProxyTargetV2[]
),
newTargets: convertSubnetProxyTargetsV2ToV1(
targets.newTargets as SubnetProxyTargetV2[]
)
};
}
await sendToClient( await sendToClient(
newtId, newtId,
{ {

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

@@ -1,6 +1,5 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { eq, sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { sites } from "@server/db";
import { db } from "@server/db"; import { db } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -31,7 +30,10 @@ const MAX_RETRIES = 3;
const BASE_DELAY_MS = 50; const BASE_DELAY_MS = 50;
// How often to flush accumulated bandwidth data to the database // How often to flush accumulated bandwidth data to the database
const FLUSH_INTERVAL_MS = 30_000; // 30 seconds const FLUSH_INTERVAL_MS = 300_000; // 300 seconds
// Maximum number of sites to include in a single batch UPDATE statement
const BATCH_CHUNK_SIZE = 250;
// In-memory accumulator: publicKey -> AccumulatorEntry // In-memory accumulator: publicKey -> AccumulatorEntry
let accumulator = new Map<string, AccumulatorEntry>(); let accumulator = new Map<string, AccumulatorEntry>();
@@ -75,13 +77,33 @@ async function withDeadlockRetry<T>(
} }
} }
/**
* Execute a raw SQL query that returns rows, in a way that works across both
* the PostgreSQL driver (which exposes `execute`) and the SQLite driver (which
* exposes `all`). Drizzle's typed query builder doesn't support bulk
* UPDATE … FROM (VALUES …) natively, so we drop to raw SQL here.
*/
async function dbQueryRows<T extends Record<string, unknown>>(
query: Parameters<(typeof sql)["join"]>[0][number]
): Promise<T[]> {
const anyDb = db as any;
if (typeof anyDb.execute === "function") {
// PostgreSQL (node-postgres via Drizzle) — returns { rows: [...] } or an array
const result = await anyDb.execute(query);
return (Array.isArray(result) ? result : (result.rows ?? [])) as T[];
}
// SQLite (better-sqlite3 via Drizzle) — returns an array directly
return (await anyDb.all(query)) as T[];
}
/** /**
* Flush all accumulated site bandwidth data to the database. * Flush all accumulated site bandwidth data to the database.
* *
* Swaps out the accumulator before writing so that any bandwidth messages * Swaps out the accumulator before writing so that any bandwidth messages
* received during the flush are captured in the new accumulator rather than * received during the flush are captured in the new accumulator rather than
* being lost or causing contention. Entries that fail to write are re-queued * being lost or causing contention. Sites are updated in chunks via a single
* back into the accumulator so they will be retried on the next flush. * batch UPDATE per chunk. Failed chunks are discarded — exact per-flush
* accuracy is not critical and re-queuing is not worth the added complexity.
* *
* This function is exported so that the application's graceful-shutdown * This function is exported so that the application's graceful-shutdown
* cleanup handler can call it before the process exits. * cleanup handler can call it before the process exits.
@@ -108,76 +130,76 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
`Flushing accumulated bandwidth data for ${sortedEntries.length} site(s) to the database` `Flushing accumulated bandwidth data for ${sortedEntries.length} site(s) to the database`
); );
// Aggregate billing usage by org, collected during the DB update loop. // Build a lookup so post-processing can reach each entry by publicKey.
const snapshotMap = new Map(sortedEntries);
// Aggregate billing usage by org across all chunks.
const orgUsageMap = new Map<string, number>(); const orgUsageMap = new Map<string, number>();
for (const [publicKey, { bytesIn, bytesOut, exitNodeId, calcUsage }] of sortedEntries) { // Process in chunks so individual queries stay at a reasonable size.
for (let i = 0; i < sortedEntries.length; i += BATCH_CHUNK_SIZE) {
const chunk = sortedEntries.slice(i, i + BATCH_CHUNK_SIZE);
const chunkEnd = i + chunk.length - 1;
// Build a parameterised VALUES list: (pubKey, bytesIn, bytesOut), ...
// Both PostgreSQL and SQLite (≥ 3.33.0, which better-sqlite3 bundles)
// support UPDATE … FROM (VALUES …), letting us update the whole chunk
// in a single query instead of N individual round-trips.
const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) =>
sql`(${publicKey}, ${bytesIn}, ${bytesOut})`
);
const valuesClause = sql.join(valuesList, sql`, `);
let rows: { orgId: string; pubKey: string }[] = [];
try { try {
const updatedSite = await withDeadlockRetry(async () => { rows = await withDeadlockRetry(async () => {
const [result] = await db return dbQueryRows<{ orgId: string; pubKey: string }>(sql`
.update(sites) UPDATE sites
.set({ SET
megabytesOut: sql`COALESCE(${sites.megabytesOut}, 0) + ${bytesIn}`, "bytesOut" = COALESCE("bytesOut", 0) + v.bytes_in,
megabytesIn: sql`COALESCE(${sites.megabytesIn}, 0) + ${bytesOut}`, "bytesIn" = COALESCE("bytesIn", 0) + v.bytes_out,
lastBandwidthUpdate: currentTime, "lastBandwidthUpdate" = ${currentTime}
}) FROM (VALUES ${valuesClause}) AS v(pub_key, bytes_in, bytes_out)
.where(eq(sites.pubKey, publicKey)) WHERE sites."pubKey" = v.pub_key
.returning({ RETURNING sites."orgId" AS "orgId", sites."pubKey" AS "pubKey"
orgId: sites.orgId, `);
siteId: sites.siteId }, `flush bandwidth chunk [${i}${chunkEnd}]`);
});
return result;
}, `flush bandwidth for site ${publicKey}`);
if (updatedSite) {
if (exitNodeId) {
const notAllowed = await checkExitNodeOrg(
exitNodeId,
updatedSite.orgId
);
if (notAllowed) {
logger.warn(
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
);
// Skip usage tracking for this site but continue
// processing the rest.
continue;
}
}
if (calcUsage) {
const totalBandwidth = bytesIn + bytesOut;
const current = orgUsageMap.get(updatedSite.orgId) ?? 0;
orgUsageMap.set(updatedSite.orgId, current + totalBandwidth);
}
}
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to flush bandwidth for site ${publicKey}:`, `Failed to flush bandwidth chunk [${i}${chunkEnd}], discarding ${chunk.length} site(s):`,
error error
); );
// Discard the chunk — exact per-flush accuracy is not critical.
continue;
}
// Re-queue the failed entry so it is retried on the next flush // Collect billing usage from the returned rows.
// rather than silently dropped. for (const { orgId, pubKey } of rows) {
const existing = accumulator.get(publicKey); const entry = snapshotMap.get(pubKey);
if (existing) { if (!entry) continue;
existing.bytesIn += bytesIn;
existing.bytesOut += bytesOut; const { bytesIn, bytesOut, exitNodeId, calcUsage } = entry;
} else {
accumulator.set(publicKey, { if (exitNodeId) {
bytesIn, const notAllowed = await checkExitNodeOrg(exitNodeId, orgId);
bytesOut, if (notAllowed) {
exitNodeId, logger.warn(
calcUsage `Exit node ${exitNodeId} is not allowed for org ${orgId}`
}); );
continue;
}
}
if (calcUsage) {
const current = orgUsageMap.get(orgId) ?? 0;
orgUsageMap.set(orgId, current + bytesIn + bytesOut);
} }
} }
} }
// Process billing usage updates outside the site-update loop to keep // Process billing usage updates after all chunks are written.
// lock scope small and concerns separated.
if (orgUsageMap.size > 0) { if (orgUsageMap.size > 0) {
// Sort org IDs for consistent lock ordering.
const sortedOrgIds = [...orgUsageMap.keys()].sort(); const sortedOrgIds = [...orgUsageMap.keys()].sort();
for (const orgId of sortedOrgIds) { for (const orgId of sortedOrgIds) {

View File

@@ -13,6 +13,7 @@ import {
orgs, orgs,
Role, Role,
roles, roles,
userOrgRoles,
userOrgs, userOrgs,
users users
} from "@server/db"; } from "@server/db";
@@ -40,6 +41,7 @@ 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 +368,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 +380,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 +404,47 @@ 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) { if (!roleNames.length) {
logger.error("Role name not found in the ID token", { logger.error("Role mapping returned no valid roles", {
roleName 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, roleNames)
) )
); );
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
}); });
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,31 +579,29 @@ export async function validateOidcCallback(
} }
} }
// Update roles for existing auto-provisioned orgs where the role has changed // Ensure IDP-provided role exists for existing auto-provisioned orgs (add only; never delete other roles)
const orgsToUpdate = autoProvisionedOrgs.filter( const userRolesInOrgs = await trx
(currentOrg) => { .select()
const newOrg = userOrgInfo.find( .from(userOrgRoles)
(newOrg) => newOrg.orgId === currentOrg.orgId .where(eq(userOrgRoles.userId, userId!));
for (const currentOrg of autoProvisionedOrgs) {
const newRole = userOrgInfo.find(
(newOrg) => newOrg.orgId === currentOrg.orgId
);
if (!newRole) continue;
const currentRolesInOrg = userRolesInOrgs.filter(
(r) => r.orgId === currentOrg.orgId
);
for (const roleId of newRole.roleIds) {
const hasIdpRole = currentRolesInOrg.some(
(r) => r.roleId === roleId
); );
return newOrg && newOrg.roleId !== currentOrg.roleId; if (!hasIdpRole) {
} await trx.insert(userOrgRoles).values({
); userId: userId!,
orgId: currentOrg.orgId,
if (orgsToUpdate.length > 0) { roleId
for (const org of orgsToUpdate) { });
const newRole = userOrgInfo.find(
(newOrg) => newOrg.orgId === org.orgId
);
if (newRole) {
await trx
.update(userOrgs)
.set({ roleId: newRole.roleId })
.where(
and(
eq(userOrgs.userId, userId!),
eq(userOrgs.orgId, org.orgId)
)
);
} }
} }
} }
@@ -609,6 +616,12 @@ export async function validateOidcCallback(
if (orgsToAdd.length > 0) { if (orgsToAdd.length > 0) {
for (const org of orgsToAdd) { for (const org of orgsToAdd) {
const [initialRoleId, ...additionalRoleIds] =
org.roleIds;
if (!initialRoleId) {
continue;
}
const [fullOrg] = await trx const [fullOrg] = await trx
.select() .select()
.from(orgs) .from(orgs)
@@ -619,11 +632,19 @@ export async function validateOidcCallback(
{ {
orgId: org.orgId, orgId: org.orgId,
userId: userId!, userId: userId!,
roleId: org.roleId,
autoProvisioned: true, autoProvisioned: true,
}, },
initialRoleId,
trx trx
); );
for (const roleId of additionalRoleIds) {
await trx.insert(userOrgRoles).values({
userId: userId!,
orgId: org.orgId,
roleId
});
}
} }
} }
} }
@@ -748,3 +769,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

@@ -16,8 +16,8 @@ import { eq, and } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { import {
formatEndpoint, formatEndpoint,
generateSubnetProxyTargets, generateSubnetProxyTargetV2,
SubnetProxyTarget SubnetProxyTargetV2
} from "@server/lib/ip"; } from "@server/lib/ip";
export async function buildClientConfigurationForNewtClient( export async function buildClientConfigurationForNewtClient(
@@ -143,7 +143,7 @@ export async function buildClientConfigurationForNewtClient(
.from(siteResources) .from(siteResources)
.where(eq(siteResources.siteId, siteId)); .where(eq(siteResources.siteId, siteId));
const targetsToSend: SubnetProxyTarget[] = []; const targetsToSend: SubnetProxyTargetV2[] = [];
for (const resource of allSiteResources) { for (const resource of allSiteResources) {
// Get clients associated with this specific resource // Get clients associated with this specific resource
@@ -168,12 +168,14 @@ export async function buildClientConfigurationForNewtClient(
) )
); );
const resourceTargets = generateSubnetProxyTargets( const resourceTarget = generateSubnetProxyTargetV2(
resource, resource,
resourceClients resourceClients
); );
targetsToSend.push(...resourceTargets); if (resourceTarget) {
targetsToSend.push(resourceTarget);
}
} }
return { return {

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

@@ -6,6 +6,7 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { convertTargetsIfNessicary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
const inputSchema = z.object({ const inputSchema = z.object({
@@ -127,13 +128,15 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
exitNode exitNode
); );
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets);
return { return {
message: { message: {
type: "newt/wg/receive-config", type: "newt/wg/receive-config",
data: { data: {
ipAddress: site.address, ipAddress: site.address,
peers, peers,
targets targets: targetsToSend
} }
}, },
options: { options: {

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

@@ -88,7 +88,7 @@ const createSiteResourceSchema = z
}, },
{ {
message: message:
"Destination must be a valid IP address or valid domain AND alias is required" "Destination must be a valid IPV4 address or valid domain AND alias is required"
} }
) )
.refine( .refine(

View File

@@ -24,7 +24,7 @@ import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import { import {
generateAliasConfig, generateAliasConfig,
generateRemoteSubnets, generateRemoteSubnets,
generateSubnetProxyTargets, generateSubnetProxyTargetV2,
isIpInCidr, isIpInCidr,
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
@@ -608,18 +608,18 @@ export async function handleMessagingForUpdatedSiteResource(
// Only update targets on newt if destination changed // Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged) { if (destinationChanged || portRangesChanged) {
const oldTargets = generateSubnetProxyTargets( const oldTarget = generateSubnetProxyTargetV2(
existingSiteResource, existingSiteResource,
mergedAllClients mergedAllClients
); );
const newTargets = generateSubnetProxyTargets( const newTarget = generateSubnetProxyTargetV2(
updatedSiteResource, updatedSiteResource,
mergedAllClients mergedAllClients
); );
await updateTargets(newt.newtId, { await updateTargets(newt.newtId, {
oldTargets: oldTargets, oldTargets: oldTarget ? [oldTarget] : [],
newTargets: newTargets newTargets: newTarget ? [newTarget] : []
}, newt.version); }, newt.version);
} }

View File

@@ -165,9 +165,9 @@ export async function acceptInvite(
org, org,
{ {
userId: existingUser[0].userId, userId: existingUser[0].userId,
orgId: existingInvite.orgId, orgId: existingInvite.orgId
roleId: existingInvite.roleId
}, },
existingInvite.roleId,
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

@@ -221,12 +221,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,
},
role.roleId,
trx
);
} else { } else {
userId = generateId(15); userId = generateId(15);
@@ -244,12 +248,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,
},
role.roleId,
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,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

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

@@ -47,6 +47,14 @@ import { ListRolesResponse } from "@server/routers/role";
import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget";
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 +64,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 +204,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 +226,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 +245,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 +321,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 +348,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
@@ -497,42 +507,43 @@ export default function GeneralPage() {
</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>

View File

@@ -42,6 +42,12 @@ 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();
@@ -228,7 +240,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 +271,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
}; };
@@ -368,43 +396,44 @@ export default function Page() {
</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>

View File

@@ -3,25 +3,18 @@
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { ListRolesResponse } from "@server/routers/role"; import { ListRolesResponse } from "@server/routers/role";
@@ -44,34 +37,73 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import IdpTypeBadge from "@app/components/IdpTypeBadge"; import IdpTypeBadge from "@app/components/IdpTypeBadge";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
const accessControlsFormSchema = z.object({
username: z.string(),
autoProvisioned: z.boolean(),
roles: z.array(
z.object({
id: z.string(),
text: z.string()
})
)
});
export default function AccessControlsPage() { export default function AccessControlsPage() {
const { orgUser: user } = userOrgUserContext(); const { orgUser: user, updateOrgUser } = userOrgUserContext();
const { env } = useEnvContext();
const api = createApiClient(useEnvContext()); const api = createApiClient({ env });
const { orgId } = useParams(); const { orgId } = useParams();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [activeRoleTagIndex, setActiveRoleTagIndex] = useState<number | null>(
null
);
const t = useTranslations(); const t = useTranslations();
const { isPaidUser, hasSaasSubscription, hasEnterpriseLicense } =
const formSchema = z.object({ usePaidStatus();
username: z.string(), const multiRoleFeatureTiers = Array.from(
roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }), new Set([...tierMatrix.sshPam, ...tierMatrix.orgOidc])
autoProvisioned: z.boolean() );
}); const isPaid = isPaidUser(multiRoleFeatureTiers);
const supportsMultipleRolesPerUser = isPaid;
const showMultiRolePaywallMessage =
!env.flags.disableEnterpriseFeatures &&
((build === "saas" && !isPaid) ||
(build === "enterprise" && !isPaid) ||
(build === "oss" && !isPaid));
const form = useForm({ const form = useForm({
resolver: zodResolver(formSchema), resolver: zodResolver(accessControlsFormSchema),
defaultValues: { defaultValues: {
username: user.username!, username: user.username!,
roleId: user.roleId?.toString(), autoProvisioned: user.autoProvisioned || false,
autoProvisioned: user.autoProvisioned || false roles: (user.roles ?? []).map((r) => ({
id: r.roleId.toString(),
text: r.name
}))
} }
}); });
const currentRoleIds = user.roleIds ?? [];
useEffect(() => {
form.setValue(
"roles",
(user.roles ?? []).map((r) => ({
id: r.roleId.toString(),
text: r.name
}))
);
}, [user.userId, currentRoleIds.join(",")]);
useEffect(() => { useEffect(() => {
async function fetchRoles() { async function fetchRoles() {
const res = await api const res = await api
@@ -94,32 +126,95 @@ export default function AccessControlsPage() {
} }
fetchRoles(); fetchRoles();
form.setValue("roleId", user.roleId.toString());
form.setValue("autoProvisioned", user.autoProvisioned || false); form.setValue("autoProvisioned", user.autoProvisioned || false);
}, []); }, []);
async function onSubmit(values: z.infer<typeof formSchema>) { const allRoleOptions = useMemo(
setLoading(true); () =>
roles
.map((role) => ({
id: role.roleId.toString(),
text: role.name
}))
.filter((role) => role.text !== "Admin"),
[roles]
);
function setRoleTags(updater: Tag[] | ((prev: Tag[]) => Tag[])) {
const prev = form.getValues("roles");
const nextValue =
typeof updater === "function" ? updater(prev) : updater;
const next = supportsMultipleRolesPerUser
? nextValue
: nextValue.length > 1
? [nextValue[nextValue.length - 1]]
: nextValue;
// In single-role mode, selecting the currently selected role can transiently
// emit an empty tag list from TagInput; keep the prior selection.
if (
!supportsMultipleRolesPerUser &&
next.length === 0 &&
prev.length > 0
) {
form.setValue("roles", [prev[prev.length - 1]], {
shouldDirty: true
});
return;
}
if (next.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
form.setValue("roles", next, { shouldDirty: true });
}
async function onSubmit(values: z.infer<typeof accessControlsFormSchema>) {
if (values.roles.length === 0) {
toast({
variant: "destructive",
title: t("accessRoleErrorAdd"),
description: t("accessRoleSelectPlease")
});
return;
}
setLoading(true);
try { try {
// Execute both API calls simultaneously const roleIds = values.roles.map((r) => parseInt(r.id, 10));
const [roleRes, userRes] = await Promise.all([ const updateRoleRequest = supportsMultipleRolesPerUser
api.post<AxiosResponse<InviteUserResponse>>( ? api.post(`/user/${user.userId}/org/${orgId}/roles`, {
`/role/${values.roleId}/add/${user.userId}` roleIds
), })
: api.post(`/role/${roleIds[0]}/add/${user.userId}`);
await Promise.all([
updateRoleRequest,
api.post(`/org/${orgId}/user/${user.userId}`, { api.post(`/org/${orgId}/user/${user.userId}`, {
autoProvisioned: values.autoProvisioned autoProvisioned: values.autoProvisioned
}) })
]); ]);
if (roleRes.status === 200 && userRes.status === 200) { updateOrgUser({
toast({ roleIds,
variant: "default", roles: values.roles.map((r) => ({
title: t("userSaved"), roleId: parseInt(r.id, 10),
description: t("userSavedDescription") name: r.text
}); })),
} autoProvisioned: values.autoProvisioned
});
toast({
variant: "default",
title: t("userSaved"),
description: t("userSavedDescription")
});
} catch (e) { } catch (e) {
toast({ toast({
variant: "destructive", variant: "destructive",
@@ -130,7 +225,6 @@ export default function AccessControlsPage() {
) )
}); });
} }
setLoading(false); setLoading(false);
} }
@@ -154,7 +248,6 @@ export default function AccessControlsPage() {
className="space-y-4" className="space-y-4"
id="access-controls-form" id="access-controls-form"
> >
{/* IDP Type Display */}
{user.type !== UserType.Internal && {user.type !== UserType.Internal &&
user.idpType && ( user.idpType && (
<div className="flex items-center space-x-2 mb-4"> <div className="flex items-center space-x-2 mb-4">
@@ -173,43 +266,48 @@ export default function AccessControlsPage() {
<FormField <FormField
control={form.control} control={form.control}
name="roleId" name="roles"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="flex flex-col items-start">
<FormLabel>{t("role")}</FormLabel> <FormLabel>{t("roles")}</FormLabel>
<Select <FormControl>
onValueChange={(value) => { <TagInput
field.onChange(value); {...field}
// If auto provision is enabled, set it to false when role changes activeTagIndex={
if (user.idpAutoProvision) { activeRoleTagIndex
form.setValue(
"autoProvisioned",
false
);
} }
}} setActiveTagIndex={
value={field.value} setActiveRoleTagIndex
> }
<FormControl> placeholder={t(
<SelectTrigger> "accessRoleSelect2"
<SelectValue )}
placeholder={t( size="sm"
"accessRoleSelect" tags={field.value}
)} setTags={setRoleTags}
/> enableAutocomplete={true}
</SelectTrigger> autocompleteOptions={
</FormControl> allRoleOptions
<SelectContent> }
{roles.map((role) => ( allowDuplicates={false}
<SelectItem restrictTagsToAutocompleteOptions={
key={role.roleId} true
value={role.roleId.toString()} }
> sortTags={true}
{role.name} disabled={loading}
</SelectItem> />
))} </FormControl>
</SelectContent> {showMultiRolePaywallMessage && (
</Select> <FormDescription>
{build === "saas"
? t(
"singleRolePerUserPlanNotice"
)
: t(
"singleRolePerUserEditionNotice"
)}
</FormDescription>
)}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}

View File

@@ -86,9 +86,14 @@ export default async function UsersPage(props: UsersPageProps) {
idpId: user.idpId, idpId: user.idpId,
idpName: user.idpName || t("idpNameInternal"), idpName: user.idpName || t("idpNameInternal"),
status: t("userConfirmed"), status: t("userConfirmed"),
role: user.isOwner roleLabels: user.isOwner
? t("accessRoleOwner") ? [t("accessRoleOwner")]
: user.roleName || t("accessRoleMember"), : (() => {
const names = (user.roles ?? [])
.map((r) => r.roleName)
.filter((n): n is string => Boolean(n?.length));
return names.length ? names : [t("accessRoleMember")];
})(),
isOwner: user.isOwner || false isOwner: user.isOwner || false
}; };
}); });

View File

@@ -493,7 +493,8 @@ export default function GeneralPage() {
{ {
value: "whitelistedEmail", value: "whitelistedEmail",
label: "Whitelisted Email" label: "Whitelisted Email"
} },
{ value: "ssh", label: "SSH" }
]} ]}
selectedValue={filters.type} selectedValue={filters.type}
onValueChange={(value) => onValueChange={(value) =>
@@ -507,13 +508,12 @@ export default function GeneralPage() {
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
// should be capitalized first letter const typeLabel =
return ( row.original.type === "ssh"
<span> ? "SSH"
{row.original.type.charAt(0).toUpperCase() + : row.original.type.charAt(0).toUpperCase() +
row.original.type.slice(1) || "-"} row.original.type.slice(1);
</span> return <span>{typeLabel || "-"}</span>;
);
} }
}, },
{ {

View File

@@ -129,12 +129,13 @@ export default function ResourceAuthenticationPage() {
orgId: org.org.orgId orgId: org.org.orgId
}) })
); );
const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery({
orgQueries.identityProviders({ ...orgQueries.identityProviders({
orgId: org.org.orgId, orgId: org.org.orgId,
useOrgOnlyIdp: env.app.identityProviderMode === "org" useOrgOnlyIdp: env.app.identityProviderMode === "org"
}) }),
); enabled: isPaidUser(tierMatrix.orgOidc)
});
const pageLoading = const pageLoading =
isLoadingOrgRoles || isLoadingOrgRoles ||

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