diff --git a/install/main.go b/install/main.go index 2bb73b1dd..a38d78fc6 100644 --- a/install/main.go +++ b/install/main.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "strings" "text/template" "time" @@ -89,6 +90,13 @@ func main() { var config Config var alreadyInstalled = false + // Determine installation directory + installDir := findOrSelectInstallDirectory() + if err := os.Chdir(installDir); err != nil { + fmt.Printf("Error changing to installation directory: %v\n", err) + os.Exit(1) + } + // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { config = collectUserInput() @@ -286,6 +294,117 @@ func main() { fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain) } +func hasExistingInstall(dir string) bool { + configPath := filepath.Join(dir, "config", "config.yml") + _, err := os.Stat(configPath) + return err == nil +} + +func findOrSelectInstallDirectory() string { + const defaultInstallDir = "/opt/pangolin" + + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + fmt.Printf("Error getting current directory: %v\n", err) + os.Exit(1) + } + + // 1. Check current directory for existing install + if hasExistingInstall(cwd) { + fmt.Printf("Found existing Pangolin installation in current directory: %s\n", cwd) + return cwd + } + + // 2. Check default location (/opt/pangolin) for existing install + if cwd != defaultInstallDir && hasExistingInstall(defaultInstallDir) { + fmt.Printf("\nFound existing Pangolin installation at: %s\n", defaultInstallDir) + if readBool(fmt.Sprintf("Would you like to use the existing installation at %s?", defaultInstallDir), true) { + return defaultInstallDir + } + } + + // 3. No existing install found, prompt for installation directory + fmt.Println("\n=== Installation Directory ===") + fmt.Println("No existing Pangolin installation detected.") + + installDir := readString("Enter the installation directory", defaultInstallDir) + + // Expand ~ to home directory if present + if strings.HasPrefix(installDir, "~") { + home, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error getting home directory: %v\n", err) + os.Exit(1) + } + installDir = filepath.Join(home, installDir[1:]) + } + + // Convert to absolute path + absPath, err := filepath.Abs(installDir) + if err != nil { + fmt.Printf("Error resolving path: %v\n", err) + os.Exit(1) + } + installDir = absPath + + // Check if directory exists + if _, err := os.Stat(installDir); os.IsNotExist(err) { + // Directory doesn't exist, create it + if readBool(fmt.Sprintf("Directory %s does not exist. Create it?", installDir), true) { + if err := os.MkdirAll(installDir, 0755); err != nil { + fmt.Printf("Error creating directory: %v\n", err) + os.Exit(1) + } + fmt.Printf("Created directory: %s\n", installDir) + + // Offer to change ownership if running via sudo + changeDirectoryOwnership(installDir) + } else { + fmt.Println("Installation cancelled.") + os.Exit(0) + } + } + + fmt.Printf("Installation directory: %s\n", installDir) + return installDir +} + +func changeDirectoryOwnership(dir string) { + // Check if we're running via sudo by looking for SUDO_USER + sudoUser := os.Getenv("SUDO_USER") + if sudoUser == "" || os.Geteuid() != 0 { + return + } + + sudoUID := os.Getenv("SUDO_UID") + sudoGID := os.Getenv("SUDO_GID") + + if sudoUID == "" || sudoGID == "" { + return + } + + fmt.Printf("\nRunning as root via sudo (original user: %s)\n", sudoUser) + if readBool(fmt.Sprintf("Would you like to change ownership of %s to user '%s'? This makes it easier to manage config files without sudo.", dir, sudoUser), true) { + uid, err := strconv.Atoi(sudoUID) + if err != nil { + fmt.Printf("Warning: Could not parse SUDO_UID: %v\n", err) + return + } + gid, err := strconv.Atoi(sudoGID) + if err != nil { + fmt.Printf("Warning: Could not parse SUDO_GID: %v\n", err) + return + } + + if err := os.Chown(dir, uid, gid); err != nil { + fmt.Printf("Warning: Could not change ownership: %v\n", err) + } else { + fmt.Printf("Changed ownership of %s to %s\n", dir, sudoUser) + } + } +} + func podmanOrDocker() SupportedContainer { inputContainer := readString("Would you like to run Pangolin as Docker or Podman containers?", "docker") diff --git a/license.py b/license.py new file mode 100644 index 000000000..865dfad7a --- /dev/null +++ b/license.py @@ -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)) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 0b96141e9..e841490b2 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -148,6 +148,11 @@ "createLink": "Създаване на връзка", "resourcesNotFound": "Не са намерени ресурси", "resourceSearch": "Търсене на ресурси", + "machineSearch": "Търсене на машини", + "machinesSearch": "Търсене на клиенти на машини...", + "machineNotFound": "Не са намерени машини", + "userDeviceSearch": "Търсене на устройства на потребителя", + "userDevicesSearch": "Търсене на устройства на потребителя...", "openMenu": "Отваряне на менюто", "resource": "Ресурс", "title": "Заглавие", @@ -323,6 +328,54 @@ "apiKeysDelete": "Изтрийте API ключа", "apiKeysManage": "Управление на API ключове", "apiKeysDescription": "API ключове се използват за удостоверяване с интеграционния API", + "provisioningKeysTitle": "Ключ за осигуряване", + "provisioningKeysManage": "Управление на ключове за осигуряване", + "provisioningKeysDescription": "Ключовете за осигуряване се използват за удостоверяване на автоматичното осигуряване на сайта за вашата организация.", + "provisioningManage": "Осигуряване", + "provisioningDescription": "Управление на ключовете за осигуряване и преглед на чаканещите сайтове за одобрение.", + "pendingSites": "Чаканещи сайтове", + "siteApproveSuccess": "Сайтът е одобрен успешно", + "siteApproveError": "Грешка при одобряването на сайта", + "provisioningKeys": "Ключове за осигуряване", + "searchProvisioningKeys": "Търсене на ключове за осигуряване...", + "provisioningKeysAdd": "Генериране на ключ за осигуряване", + "provisioningKeysErrorDelete": "Грешка при изтриване на ключ за осигуряване", + "provisioningKeysErrorDeleteMessage": "Грешка при изтриване на ключ за осигуряване", + "provisioningKeysQuestionRemove": "Сигурни ли сте, че искате да премахнете този ключ за осигуряване от организацията?", + "provisioningKeysMessageRemove": "След като бъде премахнат, ключът няма да бъде използван за осигуряване на сайтове.", + "provisioningKeysDeleteConfirm": "Потвърдете изтриването на ключ за осигуряване", + "provisioningKeysDelete": "Изтриване на ключ за осигуряване", + "provisioningKeysCreate": "Генериране на ключ за осигуряване", + "provisioningKeysCreateDescription": "Генерирайте нов ключ за осигуряване за организацията", + "provisioningKeysSeeAll": "Вижте всички ключове за осигуряване", + "provisioningKeysSave": "Запазете ключа за осигуряване", + "provisioningKeysSaveDescription": "Ще можете да видите това само веднъж. Копирайте го на сигурно място.", + "provisioningKeysErrorCreate": "Грешка при създаване на ключ за осигуряване", + "provisioningKeysList": "Нов ключ за осигуряване", + "provisioningKeysMaxBatchSize": "Максимален размер на пакет", + "provisioningKeysUnlimitedBatchSize": "Неограничен размер на партида (без лимит)", + "provisioningKeysMaxBatchUnlimited": "Неограничено", + "provisioningKeysMaxBatchSizeInvalid": "Въведете валиден максимален размер на партида (1–1,000,000).", + "provisioningKeysValidUntil": "Валиден до", + "provisioningKeysValidUntilHint": "Оставете празно за неограничено валидност.", + "provisioningKeysValidUntilInvalid": "Въведете валидна дата и час.", + "provisioningKeysNumUsed": "Брой използвания", + "provisioningKeysLastUsed": "Последно използван", + "provisioningKeysNoExpiry": "Без изтичане", + "provisioningKeysNeverUsed": "Никога", + "provisioningKeysEdit": "Редактиране на ключ за осигуряване", + "provisioningKeysEditDescription": "Актуализирайте максималния размер на партида и времето на изтичане за този ключ.", + "provisioningKeysApproveNewSites": "Одобрете нови сайтове", + "provisioningKeysApproveNewSitesDescription": "Автоматично одобряване на сайтове, които се регистрират с този ключ.", + "provisioningKeysUpdateError": "Грешка при актуализирането на ключа за осигуряване", + "provisioningKeysUpdated": "Ключът за осигуряване е актуализиран", + "provisioningKeysUpdatedDescription": "Вашите промени бяха запазени.", + "provisioningKeysBannerTitle": "Ключове за осигуряване на сайта", + "provisioningKeysBannerDescription": "Генерирайте ключ за осигуряване и го използвайте с Newt конектора за автоматично създаване на сайтове при първото стартиране — няма нужда от създаване на отделни идентификационни данни за всеки сайт.", + "provisioningKeysBannerButtonText": "Научете повече", + "pendingSitesBannerTitle": "Чакащи сайтове", + "pendingSitesBannerDescription": "Сайтовете, които се свързват чрез ключ за осигуряване, се появяват тук за преглед. Одобрете всеки сайт, преди да стане активен и да получи достъп до вашите ресурси.", + "pendingSitesBannerButtonText": "Научете повече", "apiKeysSettings": "Настройки на {apiKeyName}", "userTitle": "Управление на всички потребители", "userDescription": "Преглед и управление на всички потребители в системата", @@ -509,9 +562,12 @@ "userSaved": "Потребителят е запазен", "userSavedDescription": "Потребителят беше актуализиран.", "autoProvisioned": "Автоматично предоставено", + "autoProvisionSettings": "Настройки за автоматично осигуряване", "autoProvisionedDescription": "Позволете този потребител да бъде автоматично управляван от доставчик на идентификационни данни", "accessControlsDescription": "Управлявайте какво може да достъпва и прави този потребител в организацията", "accessControlsSubmit": "Запазване на контролите за достъп", + "singleRolePerUserPlanNotice": "Вашият план поддържа само една роля на потребител.", + "singleRolePerUserEditionNotice": "Това издание поддържа само една роля на потребител.", "roles": "Роли", "accessUsersRoles": "Управление на потребители и роли", "accessUsersRolesDescription": "Поканете потребители и ги добавете към роли, за да управлявате достъпа до организацията", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.", "setupTokenRequired": "Необходим е конфигурационен токен", "actionUpdateSite": "Актуализиране на сайт", + "actionResetSiteBandwidth": "Нулиране на честотната лента на организацията", "actionListSiteRoles": "Изброяване на позволените роли за сайта", "actionCreateResource": "Създаване на ресурс", "actionDeleteResource": "Изтриване на ресурс", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Изтрийте потребител", "actionListUsers": "Изброяване на потребители", "actionAddUserRole": "Добавяне на роля на потребител", + "actionSetUserOrgRoles": "Задайте роли на потребители", "actionGenerateAccessToken": "Генериране на токен за достъп", "actionDeleteAccessToken": "Изтриване на токен за достъп", "actionListAccessTokens": "Изброяване на токени за достъп", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Роли", "sidebarShareableLinks": "Връзки", "sidebarApiKeys": "API ключове", + "sidebarProvisioning": "Осигуряване", "sidebarSettings": "Настройки", "sidebarAllUsers": "Всички потребители", "sidebarIdentityProviders": "Идентификационни доставчици", @@ -1889,6 +1948,40 @@ "exitNode": "Изходен възел", "country": "Държава", "rulesMatchCountry": "Понастоящем на базата на изходния IP", + "region": "Регион", + "selectRegion": "Изберете регион", + "searchRegions": "Търсене на региони...", + "noRegionFound": "Регионът не е намерен.", + "rulesMatchRegion": "Изберете регионална групировка на държави", + "rulesErrorInvalidRegion": "Невалиден регион", + "rulesErrorInvalidRegionDescription": "Моля, изберете валиден регион.", + "regionAfrica": "Африка", + "regionNorthernAfrica": "Северна Африка", + "regionEasternAfrica": "Източна Африка", + "regionMiddleAfrica": "Централна Африка", + "regionSouthernAfrica": "Южна Африка", + "regionWesternAfrica": "Западна Африка", + "regionAmericas": "Америките", + "regionCaribbean": "Карибите", + "regionCentralAmerica": "Централна Америка", + "regionSouthAmerica": "Южна Америка", + "regionNorthernAmerica": "Северна Америка", + "regionAsia": "Азия", + "regionCentralAsia": "Централна Азия", + "regionEasternAsia": "Източна Азия", + "regionSouthEasternAsia": "Югоизточна Азия", + "regionSouthernAsia": "Южна Азия", + "regionWesternAsia": "Западна Азия", + "regionEurope": "Европа", + "regionEasternEurope": "Източна Европа", + "regionNorthernEurope": "Северна Европа", + "regionSouthernEurope": "Южна Европа", + "regionWesternEurope": "Западна Европа", + "regionOceania": "Океания", + "regionAustraliaAndNewZealand": "Австралия и Нова Зеландия", + "regionMelanesia": "Меланезия", + "regionMicronesia": "Микронезия", + "regionPolynesia": "Полинезия", "managedSelfHosted": { "title": "Управлявано Самостоятелно-хоствано", "description": "По-надежден и по-нисък поддръжка на Самостоятелно-хостван Панголиин сървър с допълнителни екстри", @@ -1937,6 +2030,25 @@ "invalidValue": "Невалидна стойност", "idpTypeLabel": "Тип на доставчика на идентичност", "roleMappingExpressionPlaceholder": "напр.: contains(groups, 'admin') && 'Admin' || 'Member'", + "roleMappingModeFixedRoles": "Фиксирани роли", + "roleMappingModeMappingBuilder": "Строител на карти", + "roleMappingModeRawExpression": "Необработено израз", + "roleMappingFixedRolesPlaceholderSelect": "Изберете една или повече роли", + "roleMappingFixedRolesPlaceholderFreeform": "Въведете имена на роли (точно съвпадение на организацията)", + "roleMappingFixedRolesDescriptionSameForAll": "Присвойте същият набор от роли на всеки автоматично осигурен потребител.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "За стандартните политики въведете имена на роли, които съществуват във всяка организация, където е осигурен потребител. Имената трябва да съвпадат точно.", + "roleMappingClaimPath": "Път на иск", + "roleMappingClaimPathPlaceholder": "групи", + "roleMappingClaimPathDescription": "Път в съдържанието на маркера, който съдържа изходни стойности (например групи).", + "roleMappingMatchValue": "Съвпадение на стойност", + "roleMappingAssignRoles": "Присвояване на роли", + "roleMappingAddMappingRule": "Добавяне на правило за картироване", + "roleMappingRawExpressionResultDescription": "Изразът трябва да бъде оценен на низ или масив от низове.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Изразът трябва да бъде оценен на низ (едно име на роля).", + "roleMappingMatchValuePlaceholder": "Съвпадение на стойност (например: администратор)", + "roleMappingAssignRolesPlaceholderFreeform": "Въведете имена на роли (точно по организация)", + "roleMappingBuilderFreeformRowHint": "Имената на ролите трябва да съвпадат с роля във всяка целева организация.", + "roleMappingRemoveRule": "Премахни", "idpGoogleConfiguration": "Конфигурация на Google", "idpGoogleConfigurationDescription": "Конфигурирайте Google OAuth2 идентификационни данни", "idpGoogleClientIdDescription": "Google OAuth2 идентификационен клиент", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп", "logRetentionActionLabel": "Задържане на логове за действия", "logRetentionActionDescription": "Колко дълго да се задържат логовете за действия", + "logRetentionConnectionLabel": "Запазване на дневниците на връзките", + "logRetentionConnectionDescription": "Колко дълго да се съхраняват дневниците на връзките", "logRetentionDisabled": "Деактивирано", "logRetention3Days": "3 дни", "logRetention7Days": "7 дни", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Край на следващата година", "actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация", "accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация", + "connectionLogs": "Логове на връзката", + "connectionLogsDescription": "Вижте логовете на връзките за тунелите в тази организация", + "sidebarLogsConnection": "Логове на връзката", + "sidebarLogsStreaming": "Потоци", + "sourceAddress": "Източен адрес", + "destinationAddress": "Адрес на дестинация", + "duration": "Продължителност", "licenseRequiredToUse": "Изисква се лиценз за Enterprise Edition или Pangolin Cloud за използване на тази функция. Резервирайте демонстрация или пробен POC.", "ossEnterpriseEditionRequired": "Enterprise Edition е необходим за използване на тази функция. Тази функция също е налична в Pangolin Cloud. Резервирайте демонстрация или пробен POC.", "certResolver": "Решавач на сертификати", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Редактирайте ролята и активирайте опцията 'Изискване на одобрения за устройства'. Потребители с тази роля ще трябва администраторско одобрение за нови устройства.", "approvalsEmptyStatePreviewDescription": "Преглед: Когато е активирано, чакащите заявки за устройства ще се появят тук за преглед", "approvalsEmptyStateButtonText": "Управлявайте роли", - "domainErrorTitle": "Имаме проблем с проверката на вашия домейн" + "domainErrorTitle": "Имаме проблем с проверката на вашия домейн", + "idpAdminAutoProvisionPoliciesTabHint": "Конфигурирайте картографирането на ролите и организационните политики на раздела Настройки за автоматично осигуряване.", + "streamingTitle": "Събитийни потоци", + "streamingDescription": "Предавайте събития от вашата организация до външни дестинации в реално време.", + "streamingUnnamedDestination": "Неименувана дестинация", + "streamingNoUrlConfigured": "Не е конфигуриран URL", + "streamingAddDestination": "Добавяне на дестинация", + "streamingHttpWebhookTitle": "HTTP Уеб хук", + "streamingHttpWebhookDescription": "Изпратете събития до всяка HTTP крайна точка с гъвкаво удостоверяване и шаблониране.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Предавайте събития на хранилище, съвместимо с S3. Очаквайте скоро.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Пресочвайте събития директно към вашият акаунт в Datadog. Очаквайте скоро.", + "streamingTypePickerDescription": "Изберете вид на дестинацията, за да започнете.", + "streamingFailedToLoad": "Неуспешно зареждане на дестинации", + "streamingUnexpectedError": "Възникна неочаквана грешка.", + "streamingFailedToUpdate": "Неуспешно актуализиране на дестинация", + "streamingDeletedSuccess": "Дестинацията беше изтрита успешно", + "streamingFailedToDelete": "Неуспешно изтриване на дестинацията", + "streamingDeleteTitle": "Изтриване на дестинация", + "streamingDeleteButtonText": "Изтриване на дестинация", + "streamingDeleteDialogAreYouSure": "Сигурни ли сте, че искате да изтриете", + "streamingDeleteDialogThisDestination": "тази дестинация", + "streamingDeleteDialogPermanentlyRemoved": "? Всички конфигурации ще бъдат премахнати завинаги.", + "httpDestEditTitle": "Редактиране на дестинация", + "httpDestAddTitle": "Добавяне на HTTP дестинация", + "httpDestEditDescription": "Актуализирайте конфигурацията за този HTTP събитий.", + "httpDestAddDescription": "Конфигурирайте нов HTTP крайна точка, за да получавате събития на вашата организация.", + "httpDestTabSettings": "Настройки", + "httpDestTabHeaders": "Заглавки", + "httpDestTabBody": "Тяло", + "httpDestTabLogs": "Логове", + "httpDestNamePlaceholder": "Моята HTTP дестинация", + "httpDestUrlLabel": "Дестинация URL", + "httpDestUrlErrorHttpRequired": "URL адресът трябва да използва http или https", + "httpDestUrlErrorHttpsRequired": "SSL е необходимо за облачни инсталации", + "httpDestUrlErrorInvalid": "Въведете валиден URL (напр. https://example.com/webhook)", + "httpDestAuthTitle": "Удостоверяване", + "httpDestAuthDescription": "Изберете как заявленията ви се удостоверяват.", + "httpDestAuthNoneTitle": "Без удостоверяване", + "httpDestAuthNoneDescription": "Изпращане на заявки без заглавие за удостоверяване.", + "httpDestAuthBearerTitle": "Bearer Токен", + "httpDestAuthBearerDescription": "Добавя заглавие за удостоверяване Bearer към всяка заявка.", + "httpDestAuthBearerPlaceholder": "Вашият API ключ или токен", + "httpDestAuthBasicTitle": "Основно удостоверяване", + "httpDestAuthBasicDescription": "Добавя заглавие за удостоверяване Basic към всяка заявка. Осигурете идентификационни данни като потребителско име:парола.", + "httpDestAuthBasicPlaceholder": "потребителско име:парола", + "httpDestAuthCustomTitle": "Персонализирано заглавие", + "httpDestAuthCustomDescription": "Посочете персонализирано име и стойност на заглавието за удостоверяване (например X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Имя на заглавието (напр. X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Стойност на заглавието", + "httpDestCustomHeadersTitle": "Персонализирани заглавия за HTTP", + "httpDestCustomHeadersDescription": "Добавяне на персонализирани заглавия към всяка изходяща заявка. Полезно за статични токени или персонални Content-Type. По подразбиране се изпраща Content-Type: application/json.", + "httpDestNoHeadersConfigured": "Персонализирани заглавия не са конфигурирани. Кликнете \"Добавяне на заглавие\" да добавите такова.", + "httpDestHeaderNamePlaceholder": "Име на заглавието", + "httpDestHeaderValuePlaceholder": "Стойност на заглавието", + "httpDestAddHeader": "Добавяне на заглавие", + "httpDestBodyTemplateTitle": "Шаблон на персонализирано тяло", + "httpDestBodyTemplateDescription": "Управлявайте структурата на JSON съобщението, изпратено до вашата крайна точка. Ако е деактивирано, по подразбиране се изпраща JSON обект за всяко събитие.", + "httpDestEnableBodyTemplate": "Активиране на персонализиран шаблон на тяло", + "httpDestBodyTemplateLabel": "Шаблон за тяло (JSON)", + "httpDestBodyTemplateHint": "Използвайте шаблонни променливи за позоваване на полетата на събитията в съобщението си.", + "httpDestPayloadFormatTitle": "Формат на полезния товар", + "httpDestPayloadFormatDescription": "Как се сериализират събитията във всеки заявка.", + "httpDestFormatJsonArrayTitle": "JSON масив", + "httpDestFormatJsonArrayDescription": "Една заявка на партида, тялото е JSON масив. Съвместим с повечето общи уеб куки и Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Една заявка на партида, тялото е ново линии отделени JSON — един обект на ред, няма външен масив. Изисквано от Splunk HEC, Elastic / OpenSearch и Grafana.", + "httpDestFormatSingleTitle": "Едно събитие на заявка", + "httpDestFormatSingleDescription": "Изпращат се отделни HTTP POST за всяко индивидуално събитие. Използвайте само за крайни точки, които не могат да обработват партиди.", + "httpDestLogTypesTitle": "Видове логове", + "httpDestLogTypesDescription": "Изберете кои видове журнални записи ще се предават към тази дестинация. Предаването ще се прави само за активирани видове журнални записи.", + "httpDestAccessLogsTitle": "Логове за достъп", + "httpDestAccessLogsDescription": "Опити за достъп до ресурс, включително удостоверени и отказани заявки.", + "httpDestActionLogsTitle": "Логове на действия", + "httpDestActionLogsDescription": "Административни действия, извършени от потребители в организацията.", + "httpDestConnectionLogsTitle": "Логове на връзката", + "httpDestConnectionLogsDescription": "Събития на свързване и прекъсване на сайта и тунела, включително свръзки и прекъсвания.", + "httpDestRequestLogsTitle": "Заявки за логове", + "httpDestRequestLogsDescription": "Регистри за HTTP заявките към проксирани ресурси, включително метод, път и код на отговор.", + "httpDestSaveChanges": "Запази промените", + "httpDestCreateDestination": "Създаване на дестинация", + "httpDestUpdatedSuccess": "Дестинацията беше актуализирана успешно", + "httpDestCreatedSuccess": "Дестинацията беше създадена успешно", + "httpDestUpdateFailed": "Неуспешно актуализиране на дестинацията", + "httpDestCreateFailed": "Неуспешно създаване на дестинацията" } diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index cb5372b36..fe5de4199 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -148,6 +148,11 @@ "createLink": "Vytvořit odkaz", "resourcesNotFound": "Nebyly nalezeny žádné zdroje", "resourceSearch": "Vyhledat zdroje", + "machineSearch": "Vyhledávací stroje", + "machinesSearch": "Hledat klienty stroje...", + "machineNotFound": "Nebyly nalezeny žádné stroje", + "userDeviceSearch": "Hledat uživatelská zařízení", + "userDevicesSearch": "Hledat uživatelská zařízení...", "openMenu": "Otevřít nabídku", "resource": "Zdroj", "title": "Název", @@ -323,6 +328,54 @@ "apiKeysDelete": "Odstranit klíč API", "apiKeysManage": "Správa API klíčů", "apiKeysDescription": "API klíče se používají k ověření s integračním API", + "provisioningKeysTitle": "Zajišťovací klíč", + "provisioningKeysManage": "Spravovat zajišťovací klíče", + "provisioningKeysDescription": "Zajišťovací klíče slouží k ověření automatického poskytování služeb vaší organizaci.", + "provisioningManage": "Zajištění", + "provisioningDescription": "Spravovat klíče pro nastavení a zkontrolovat čekající stránky čekající na schválení.", + "pendingSites": "Nevyřízené weby", + "siteApproveSuccess": "Web byl úspěšně schválen", + "siteApproveError": "Chyba při schvalování webu", + "provisioningKeys": "Poskytovací klíče", + "searchProvisioningKeys": "Hledat klíče k zajišťování...", + "provisioningKeysAdd": "Generovat zajišťovací klíč", + "provisioningKeysErrorDelete": "Chyba při odstraňování klíče pro úpravu", + "provisioningKeysErrorDeleteMessage": "Chyba při odstraňování klíče pro úpravu", + "provisioningKeysQuestionRemove": "Jste si jisti, že chcete odstranit tento konfigurační klíč z organizace?", + "provisioningKeysMessageRemove": "Jakmile je klíč odstraněn, nelze již použít pro poskytování služeb.", + "provisioningKeysDeleteConfirm": "Potvrdit odstranění zajišťovacího klíče", + "provisioningKeysDelete": "Odstranit zajišťovací klíč", + "provisioningKeysCreate": "Generovat zajišťovací klíč", + "provisioningKeysCreateDescription": "Vygenerovat nový klíč pro organizaci", + "provisioningKeysSeeAll": "Zobrazit všechny doplňovací klíče", + "provisioningKeysSave": "Uložit konfigurační klíč", + "provisioningKeysSaveDescription": "Toto můžete vidět pouze jednou. Zkopírujte ho na bezpečné místo.", + "provisioningKeysErrorCreate": "Chyba při vytváření doplňovacího klíče", + "provisioningKeysList": "Nový klíč pro poskytování informací", + "provisioningKeysMaxBatchSize": "Maximální velikost dávky", + "provisioningKeysUnlimitedBatchSize": "Neomezená velikost šarže (bez omezení)", + "provisioningKeysMaxBatchUnlimited": "Bez omezení", + "provisioningKeysMaxBatchSizeInvalid": "Zadejte platnou maximální velikost šarže (1–1,000,000).", + "provisioningKeysValidUntil": "Platné do", + "provisioningKeysValidUntilHint": "Ponechte prázdné, pokud vyprší platnost.", + "provisioningKeysValidUntilInvalid": "Zadejte platné datum a čas.", + "provisioningKeysNumUsed": "Časy použití", + "provisioningKeysLastUsed": "Naposledy použito", + "provisioningKeysNoExpiry": "Bez vypršení platnosti", + "provisioningKeysNeverUsed": "Nikdy", + "provisioningKeysEdit": "Upravit zajišťovací klíč", + "provisioningKeysEditDescription": "Aktualizujte maximální velikost dávky a dobu vypršení platnosti tohoto klíče.", + "provisioningKeysApproveNewSites": "Schválit nové stránky", + "provisioningKeysApproveNewSitesDescription": "Automaticky schvalovat weby, které se registrují pomocí tohoto klíče.", + "provisioningKeysUpdateError": "Chyba při aktualizaci klíče", + "provisioningKeysUpdated": "Zajišťovací klíč byl aktualizován", + "provisioningKeysUpdatedDescription": "Vaše změny byly uloženy.", + "provisioningKeysBannerTitle": "Klíče pro poskytování webu", + "provisioningKeysBannerDescription": "Vygenerujte konfigurační klíč a používejte jej pomocí nového konektoru k automatickému vytváření stránek při prvním startu – není třeba nastavovat samostatné přihlašovací údaje pro každý web.", + "provisioningKeysBannerButtonText": "Zjistit více", + "pendingSitesBannerTitle": "Nevyřízené weby", + "pendingSitesBannerDescription": "Zde se zobrazují stránky, které se připojují pomocí doplňovacího klíče. Schválte každý web předtím, než bude aktivní, a získejte přístup k vašim zdrojům.", + "pendingSitesBannerButtonText": "Zjistit více", "apiKeysSettings": "Nastavení {apiKeyName}", "userTitle": "Spravovat všechny uživatele", "userDescription": "Zobrazit a spravovat všechny uživatele v systému", @@ -509,9 +562,12 @@ "userSaved": "Uživatel uložen", "userSavedDescription": "Uživatel byl aktualizován.", "autoProvisioned": "Automaticky poskytnuto", + "autoProvisionSettings": "Automatická nastavení", "autoProvisionedDescription": "Povolit tomuto uživateli automaticky spravovat poskytovatel identity", "accessControlsDescription": "Spravovat co může tento uživatel přistupovat a dělat v organizaci", "accessControlsSubmit": "Uložit kontroly přístupu", + "singleRolePerUserPlanNotice": "Váš plán podporuje pouze jednu roli na uživatele.", + "singleRolePerUserEditionNotice": "Tato verze podporuje pouze jednu roli na uživatele.", "roles": "Role", "accessUsersRoles": "Spravovat uživatele a role", "accessUsersRolesDescription": "Pozvěte uživatele a přidejte je do rolí pro správu přístupu k organizaci", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Zadejte nastavovací token z konzole serveru.", "setupTokenRequired": "Je vyžadován token nastavení", "actionUpdateSite": "Aktualizovat stránku", + "actionResetSiteBandwidth": "Resetovat šířku pásma organizace", "actionListSiteRoles": "Seznam povolených rolí webu", "actionCreateResource": "Vytvořit zdroj", "actionDeleteResource": "Odstranit dokument", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Odstranit uživatele", "actionListUsers": "Seznam uživatelů", "actionAddUserRole": "Přidat uživatelskou roli", + "actionSetUserOrgRoles": "Nastavit uživatelské role", "actionGenerateAccessToken": "Generovat přístupový token", "actionDeleteAccessToken": "Odstranit přístupový token", "actionListAccessTokens": "Seznam přístupových tokenů", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Role", "sidebarShareableLinks": "Odkazy", "sidebarApiKeys": "API klíče", + "sidebarProvisioning": "Zajištění", "sidebarSettings": "Nastavení", "sidebarAllUsers": "Všichni uživatelé", "sidebarIdentityProviders": "Poskytovatelé identity", @@ -1889,6 +1948,40 @@ "exitNode": "Ukončit uzel", "country": "L 343, 22.12.2009, s. 1).", "rulesMatchCountry": "Aktuálně založené na zdrojové IP adrese", + "region": "Oblasti", + "selectRegion": "Vyberte region", + "searchRegions": "Hledat regiony...", + "noRegionFound": "Nebyl nalezen žádný region.", + "rulesMatchRegion": "Vyberte regionální seskupení zemí", + "rulesErrorInvalidRegion": "Neplatný region", + "rulesErrorInvalidRegionDescription": "Vyberte prosím platný region.", + "regionAfrica": "Afrika", + "regionNorthernAfrica": "Severní Afrika", + "regionEasternAfrica": "Východní Afrika", + "regionMiddleAfrica": "Střední Afrika", + "regionSouthernAfrica": "Jižní Afrika", + "regionWesternAfrica": "Západní Afrika", + "regionAmericas": "Ameriky", + "regionCaribbean": "Karibské", + "regionCentralAmerica": "Střední Amerika", + "regionSouthAmerica": "Jižní Amerika", + "regionNorthernAmerica": "Severní Amerika", + "regionAsia": "Asie", + "regionCentralAsia": "Střední Asie", + "regionEasternAsia": "Východní Asie", + "regionSouthEasternAsia": "jihovýchodní Asie", + "regionSouthernAsia": "Jižní Asie", + "regionWesternAsia": "Západní Asie", + "regionEurope": "L 347, 20.12.2013, s. 965).", + "regionEasternEurope": "Východní Evropa", + "regionNorthernEurope": "Severní Evropa", + "regionSouthernEurope": "Jižní Evropa", + "regionWesternEurope": "Západní Evropa", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Austrálie a Nový Zéland", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "Spravované vlastní hostování", "description": "Spolehlivější a nízko udržovaný Pangolinův server s dalšími zvony a bičkami", @@ -1937,6 +2030,25 @@ "invalidValue": "Neplatná hodnota", "idpTypeLabel": "Typ poskytovatele identity", "roleMappingExpressionPlaceholder": "např. obsahuje(skupiny, 'admin') && 'Admin' || 'Member'", + "roleMappingModeFixedRoles": "Pevné role", + "roleMappingModeMappingBuilder": "Tvorba mapování", + "roleMappingModeRawExpression": "Surový výraz", + "roleMappingFixedRolesPlaceholderSelect": "Vyberte jednu nebo více rolí", + "roleMappingFixedRolesPlaceholderFreeform": "Napište názvy rolí (shoda podle organizace)", + "roleMappingFixedRolesDescriptionSameForAll": "Přiřadit stejnou roli nastavenou každému uživateli automatického poskytování.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "Pro výchozí zásady zadejte názvy rolí, které existují v každé organizaci, kde jsou uživatelé poskytováni. Jména musí přesně odpovídat.", + "roleMappingClaimPath": "Cesta k žádosti", + "roleMappingClaimPathPlaceholder": "skupiny", + "roleMappingClaimPathDescription": "Cesta k užitečnému zatížení tokenu, která obsahuje zdrojové hodnoty (například skupiny).", + "roleMappingMatchValue": "Hodnota zápasu", + "roleMappingAssignRoles": "Přiřadit role", + "roleMappingAddMappingRule": "Přidat pravidlo pro mapování", + "roleMappingRawExpressionResultDescription": "Výraz se musí vyhodnotit do pole řetězce nebo řetězce.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Výraz musí být vyhodnocen na řetězec (jediný název role).", + "roleMappingMatchValuePlaceholder": "Hodnota zápasu (například: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Napište názvy rolí (exact per org)", + "roleMappingBuilderFreeformRowHint": "Názvy rolí musí odpovídat roli v každé cílové organizaci.", + "roleMappingRemoveRule": "Odstranit", "idpGoogleConfiguration": "Konfigurace Google", "idpGoogleConfigurationDescription": "Konfigurace přihlašovacích údajů Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Jak dlouho uchovávat přístupové záznamy", "logRetentionActionLabel": "Uchovávání protokolu akcí", "logRetentionActionDescription": "Jak dlouho uchovávat záznamy akcí", + "logRetentionConnectionLabel": "Uchovávání protokolu připojení", + "logRetentionConnectionDescription": "Jak dlouho uchovávat protokoly připojení", "logRetentionDisabled": "Zakázáno", "logRetention3Days": "3 dny", "logRetention7Days": "7 dní", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Konec následujícího roku", "actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci", "accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci", + "connectionLogs": "Protokoly připojení", + "connectionLogsDescription": "Zobrazit protokoly připojení pro tunely v této organizaci", + "sidebarLogsConnection": "Protokoly připojení", + "sidebarLogsStreaming": "Streamování", + "sourceAddress": "Zdrojová adresa", + "destinationAddress": "Cílová adresa", + "duration": "Doba trvání", "licenseRequiredToUse": "Pro použití této funkce je vyžadována licence Enterprise Edition nebo Pangolin Cloud . Zarezervujte si demo nebo POC zkušební verzi.", "ossEnterpriseEditionRequired": "Enterprise Edition je vyžadována pro použití této funkce. Tato funkce je také k dispozici v Pangolin Cloud. Rezervujte si demo nebo POC zkušební verzi.", "certResolver": "Oddělovač certifikátů", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Upravte roli a povolte možnost 'Vyžadovat schválení zařízení'. Uživatelé s touto rolí budou potřebovat schválení pro nová zařízení správce.", "approvalsEmptyStatePreviewDescription": "Náhled: Pokud je povoleno, čekající na zařízení se zde zobrazí žádosti o recenzi", "approvalsEmptyStateButtonText": "Spravovat role", - "domainErrorTitle": "Máme problém s ověřením tvé domény" + "domainErrorTitle": "Máme problém s ověřením tvé domény", + "idpAdminAutoProvisionPoliciesTabHint": "Nastavte pravidla mapování rolí a organizace na kartě Automatická úprava nastavení.", + "streamingTitle": "Streamování událostí", + "streamingDescription": "Streamujte události z vaší organizace do externích destinací v reálném čase.", + "streamingUnnamedDestination": "Nepojmenovaný cíl", + "streamingNoUrlConfigured": "Není nakonfigurována žádná URL", + "streamingAddDestination": "Přidat cíl", + "streamingHttpWebhookTitle": "HTTP webový háček", + "streamingHttpWebhookDescription": "Odeslat události na libovolný HTTP koncový bod s pružnou autentizací a šablonou.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Streamujte události do úložiště, které je kompatibilní se S3. Brzy přijde.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Přeposlat události přímo do vašeho účtu Datadog účtu. Brzy přijde.", + "streamingTypePickerDescription": "Vyberte cílový typ pro začátek.", + "streamingFailedToLoad": "Nepodařilo se načíst destinace", + "streamingUnexpectedError": "Došlo k neočekávané chybě.", + "streamingFailedToUpdate": "Nepodařilo se aktualizovat cíl", + "streamingDeletedSuccess": "Cíl byl úspěšně odstraněn", + "streamingFailedToDelete": "Nepodařilo se odstranit cíl", + "streamingDeleteTitle": "Odstranit cíl", + "streamingDeleteButtonText": "Odstranit cíl", + "streamingDeleteDialogAreYouSure": "Jste si jisti, že chcete odstranit", + "streamingDeleteDialogThisDestination": "tato destinace", + "streamingDeleteDialogPermanentlyRemoved": "? Všechny konfigurace budou trvale odstraněny.", + "httpDestEditTitle": "Upravit cíl", + "httpDestAddTitle": "Přidat cíl HTTP", + "httpDestEditDescription": "Aktualizovat konfiguraci pro tuto destinaci HTTP události", + "httpDestAddDescription": "Konfigurace nového koncového bodu HTTP pro příjem událostí vaší organizace.", + "httpDestTabSettings": "Nastavení", + "httpDestTabHeaders": "Záhlaví", + "httpDestTabBody": "Tělo", + "httpDestTabLogs": "Logy", + "httpDestNamePlaceholder": "Moje HTTP cíl", + "httpDestUrlLabel": "Cílová adresa URL", + "httpDestUrlErrorHttpRequired": "URL musí používat http nebo https", + "httpDestUrlErrorHttpsRequired": "HTTPS je vyžadován při nasazení do cloudu", + "httpDestUrlErrorInvalid": "Zadejte platnou URL (např. https://example.com/webhook)", + "httpDestAuthTitle": "Autentifikace", + "httpDestAuthDescription": "Zvolte, jak jsou požadavky na tvůj koncový bod ověřeny.", + "httpDestAuthNoneTitle": "Žádné ověření", + "httpDestAuthNoneDescription": "Odešle žádosti bez záhlaví autorizace.", + "httpDestAuthBearerTitle": "Token na doručitele", + "httpDestAuthBearerDescription": "Přidá autorizaci: Hlavička Bearer ke každému požadavku.", + "httpDestAuthBearerPlaceholder": "Váš API klíč nebo token", + "httpDestAuthBasicTitle": "Základní ověření", + "httpDestAuthBasicDescription": "Přidá autorizaci: Základní hlavička. Poskytněte přihlašovací údaje jako uživatelské jméno:password.", + "httpDestAuthBasicPlaceholder": "uživatelské jméno:heslo", + "httpDestAuthCustomTitle": "Vlastní záhlaví", + "httpDestAuthCustomDescription": "Zadejte název a hodnotu vlastního HTTP hlavičky pro ověření (např. X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Název záhlaví (např. X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Hodnota záhlaví", + "httpDestCustomHeadersTitle": "Vlastní HTTP hlavičky", + "httpDestCustomHeadersDescription": "Přidat vlastní hlavičky ke každému odchozímu požadavku. Užitečné pro statické tokeny nebo vlastní Typ obsahu. Ve výchozím nastavení je typ obsahu: application/json.", + "httpDestNoHeadersConfigured": "Nejsou nakonfigurovány žádné vlastní záhlaví. Pro přidání klikněte na \"Přidat záhlaví\".", + "httpDestHeaderNamePlaceholder": "Název záhlaví", + "httpDestHeaderValuePlaceholder": "Hodnota", + "httpDestAddHeader": "Přidat záhlaví", + "httpDestBodyTemplateTitle": "Vlastní šablona těla", + "httpDestBodyTemplateDescription": "Ovládá strukturu užitečného zatížení JSON odeslanou na váš koncový bod. Pokud je vypnuto, je pro každou událost zaslán výchozí objekt JSON.", + "httpDestEnableBodyTemplate": "Povolit vlastní šablonu těla", + "httpDestBodyTemplateLabel": "Šablona těla (JSON)", + "httpDestBodyTemplateHint": "Použijte šablonové proměnné pro referenční pole události ve vašem užitečném zatížení.", + "httpDestPayloadFormatTitle": "Formát datového zatížení", + "httpDestPayloadFormatDescription": "Jak jsou události serializovány v každém žádajícím subjektu.", + "httpDestFormatJsonArrayTitle": "JSON pole", + "httpDestFormatJsonArrayDescription": "Jeden požadavek na každou šarži, tělo je pole JSON. Kompatibilní s většinou generických webových háčků a Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Jeden požadavek na každou šarži, tělo je nově ohraničené JSON – jeden objekt na jednu čáru, bez vnějšího pole. Vyžaduje Splunk HEC, Elastic / OpenSearch, a Grafana Loki.", + "httpDestFormatSingleTitle": "Jedna událost na požadavek", + "httpDestFormatSingleDescription": "Odešle samostatnou HTTP POST pro každou jednotlivou událost. Používejte pouze pro koncové body, které nemohou zpracovávat dávky.", + "httpDestLogTypesTitle": "Typy protokolů", + "httpDestLogTypesDescription": "Vyberte, které typy logů jsou přesměrovány do této destinace. Budou streamovány pouze povolené typy logů.", + "httpDestAccessLogsTitle": "Protokoly přístupu", + "httpDestAccessLogsDescription": "Pokusy o přístup k dokumentům, včetně ověřených a zamítnutých požadavků.", + "httpDestActionLogsTitle": "Záznamy akcí", + "httpDestActionLogsDescription": "Správní opatření prováděná uživateli v rámci organizace.", + "httpDestConnectionLogsTitle": "Protokoly připojení", + "httpDestConnectionLogsDescription": "Události týkající se připojení lokality a tunelu, včetně připojení a odpojení.", + "httpDestRequestLogsTitle": "Záznamy požadavků", + "httpDestRequestLogsDescription": "HTTP záznamy požadavků pro proxy zdroje, včetně metod, cesty a kódu odpovědi.", + "httpDestSaveChanges": "Uložit změny", + "httpDestCreateDestination": "Vytvořit cíl", + "httpDestUpdatedSuccess": "Cíl byl úspěšně aktualizován", + "httpDestCreatedSuccess": "Cíl byl úspěšně vytvořen", + "httpDestUpdateFailed": "Nepodařilo se aktualizovat cíl", + "httpDestCreateFailed": "Nepodařilo se vytvořit cíl" } diff --git a/messages/de-DE.json b/messages/de-DE.json index 150a8597e..0b72ececd 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -148,6 +148,11 @@ "createLink": "Link erstellen", "resourcesNotFound": "Keine Ressourcen gefunden", "resourceSearch": "Suche Ressourcen", + "machineSearch": "Maschinen suchen", + "machinesSearch": "Suche Maschinen-Klienten...", + "machineNotFound": "Keine Maschinen gefunden", + "userDeviceSearch": "Benutzergeräte durchsuchen", + "userDevicesSearch": "Benutzergeräte durchsuchen...", "openMenu": "Menü öffnen", "resource": "Ressource", "title": "Titel", @@ -323,6 +328,54 @@ "apiKeysDelete": "API-Schlüssel löschen", "apiKeysManage": "API-Schlüssel verwalten", "apiKeysDescription": "API-Schlüssel werden zur Authentifizierung mit der Integrations-API verwendet", + "provisioningKeysTitle": "Bereitstellungsschlüssel", + "provisioningKeysManage": "Bereitstellungsschlüssel verwalten", + "provisioningKeysDescription": "Bereitstellungsschlüssel werden verwendet, um die automatisierte Bereitstellung von Seiten für Ihr Unternehmen zu authentifizieren.", + "provisioningManage": "Bereitstellung", + "provisioningDescription": "Bereitstellungsschlüssel verwalten und ausstehende Seiten prüfen, die noch auf Genehmigung warten.", + "pendingSites": "Ausstehende Seiten", + "siteApproveSuccess": "Site erfolgreich freigegeben", + "siteApproveError": "Fehler beim Bestätigen der Seite", + "provisioningKeys": "Bereitstellungsschlüssel", + "searchProvisioningKeys": "Bereitstellungsschlüssel suchen...", + "provisioningKeysAdd": "Bereitstellungsschlüssel generieren", + "provisioningKeysErrorDelete": "Fehler beim Löschen des Bereitstellungsschlüssels", + "provisioningKeysErrorDeleteMessage": "Fehler beim Löschen des Bereitstellungsschlüssels", + "provisioningKeysQuestionRemove": "Sind Sie sicher, dass Sie diesen Bereitstellungsschlüssel aus der Organisation entfernen möchten?", + "provisioningKeysMessageRemove": "Einmal entfernt, kann der Schlüssel nicht mehr für die Bereitstellung der Site verwendet werden.", + "provisioningKeysDeleteConfirm": "Bereitstellungsschlüssel löschen bestätigen", + "provisioningKeysDelete": "Bereitstellungsschlüssel löschen", + "provisioningKeysCreate": "Bereitstellungsschlüssel generieren", + "provisioningKeysCreateDescription": "Einen neuen Bereitstellungsschlüssel für die Organisation generieren", + "provisioningKeysSeeAll": "Alle Bereitstellungsschlüssel anzeigen", + "provisioningKeysSave": "Bereitstellungsschlüssel speichern", + "provisioningKeysSaveDescription": "Sie können dies nur einmal sehen. Kopieren Sie es an einen sicheren Ort.", + "provisioningKeysErrorCreate": "Fehler beim Erstellen des Bereitstellungsschlüssels", + "provisioningKeysList": "Neuer Bereitstellungsschlüssel", + "provisioningKeysMaxBatchSize": "Max. Batch-Größe", + "provisioningKeysUnlimitedBatchSize": "Unbegrenzte Batch-Größe (kein Limit)", + "provisioningKeysMaxBatchUnlimited": "Unbegrenzt", + "provisioningKeysMaxBatchSizeInvalid": "Geben Sie eine gültige maximale Batchgröße ein (1–1.000.000).", + "provisioningKeysValidUntil": "Gültig bis", + "provisioningKeysValidUntilHint": "Leer lassen für keine Verjährung.", + "provisioningKeysValidUntilInvalid": "Geben Sie ein gültiges Datum und Zeit ein.", + "provisioningKeysNumUsed": "Verwendete Zeiten", + "provisioningKeysLastUsed": "Zuletzt verwendet", + "provisioningKeysNoExpiry": "Kein Ablauf", + "provisioningKeysNeverUsed": "Nie", + "provisioningKeysEdit": "Bereitstellungsschlüssel bearbeiten", + "provisioningKeysEditDescription": "Aktualisieren Sie die maximale Batch-Größe und Ablaufzeit für diesen Schlüssel.", + "provisioningKeysApproveNewSites": "Neue Seiten genehmigen", + "provisioningKeysApproveNewSitesDescription": "Sites, die sich mit diesem Schlüssel registrieren, automatisch freigeben.", + "provisioningKeysUpdateError": "Fehler beim Aktualisieren des Bereitstellungsschlüssels", + "provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert", + "provisioningKeysUpdatedDescription": "Ihre Änderungen wurden gespeichert.", + "provisioningKeysBannerTitle": "Website-Bereitstellungsschlüssel", + "provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Konnektor, um beim ersten Start automatisch Sites zu erstellen – keine Notwendigkeit, separate Anmeldeinformationen für jede Seite einzurichten.", + "provisioningKeysBannerButtonText": "Mehr erfahren", + "pendingSitesBannerTitle": "Ausstehende Seiten", + "pendingSitesBannerDescription": "Sites, die sich mit einem Bereitstellungsschlüssel verbinden, erscheinen hier zur Überprüfung. Bestätigen Sie jede Site, bevor sie aktiv wird und erhalten Zugriff auf Ihre Ressourcen.", + "pendingSitesBannerButtonText": "Mehr erfahren", "apiKeysSettings": "{apiKeyName} Einstellungen", "userTitle": "Alle Benutzer verwalten", "userDescription": "Alle Benutzer im System anzeigen und verwalten", @@ -509,9 +562,12 @@ "userSaved": "Benutzer gespeichert", "userSavedDescription": "Der Benutzer wurde aktualisiert.", "autoProvisioned": "Automatisch bereitgestellt", + "autoProvisionSettings": "Auto-Bereitstellungseinstellungen", "autoProvisionedDescription": "Erlaube diesem Benutzer die automatische Verwaltung durch Identitätsanbieter", "accessControlsDescription": "Verwalten Sie, worauf dieser Benutzer in der Organisation zugreifen und was er tun kann", "accessControlsSubmit": "Zugriffskontrollen speichern", + "singleRolePerUserPlanNotice": "Ihr Plan unterstützt nur eine Rolle pro Benutzer.", + "singleRolePerUserEditionNotice": "Diese Ausgabe unterstützt nur eine Rolle pro Benutzer.", "roles": "Rollen", "accessUsersRoles": "Benutzer & Rollen verwalten", "accessUsersRolesDescription": "Lade Benutzer ein und füge sie zu Rollen hinzu, um den Zugriff auf die Organisation zu verwalten", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenRequired": "Setup-Token ist erforderlich", "actionUpdateSite": "Standorte aktualisieren", + "actionResetSiteBandwidth": "Organisations-Bandbreite zurücksetzen", "actionListSiteRoles": "Erlaubte Standort-Rollen auflisten", "actionCreateResource": "Ressource erstellen", "actionDeleteResource": "Ressource löschen", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Benutzer entfernen", "actionListUsers": "Benutzer auflisten", "actionAddUserRole": "Benutzerrolle hinzufügen", + "actionSetUserOrgRoles": "Benutzerrollen festlegen", "actionGenerateAccessToken": "Zugriffstoken generieren", "actionDeleteAccessToken": "Zugriffstoken löschen", "actionListAccessTokens": "Zugriffstoken auflisten", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Rollen", "sidebarShareableLinks": "Links", "sidebarApiKeys": "API-Schlüssel", + "sidebarProvisioning": "Bereitstellung", "sidebarSettings": "Einstellungen", "sidebarAllUsers": "Alle Benutzer", "sidebarIdentityProviders": "Identitätsanbieter", @@ -1889,6 +1948,40 @@ "exitNode": "Exit-Node", "country": "Land", "rulesMatchCountry": "Derzeit basierend auf der Quell-IP", + "region": "Region", + "selectRegion": "Region wählen...", + "searchRegions": "Regionen suchen...", + "noRegionFound": "Keine Region gefunden.", + "rulesMatchRegion": "Wählen Sie eine Regionalgruppe von Ländern", + "rulesErrorInvalidRegion": "Ungültige Region", + "rulesErrorInvalidRegionDescription": "Bitte wählen Sie eine gültige Region aus.", + "regionAfrica": "Afrika", + "regionNorthernAfrica": "Nordafrika", + "regionEasternAfrica": "Ostafrika", + "regionMiddleAfrica": "Zentralafrika", + "regionSouthernAfrica": "Südliches Afrika", + "regionWesternAfrica": "Westafrika", + "regionAmericas": "Amerika", + "regionCaribbean": "Karibik", + "regionCentralAmerica": "Mittelamerika", + "regionSouthAmerica": "Südamerika", + "regionNorthernAmerica": "Nordamerika", + "regionAsia": "Asien", + "regionCentralAsia": "Zentralasien", + "regionEasternAsia": "Ostasien", + "regionSouthEasternAsia": "Südostasien", + "regionSouthernAsia": "Südasien", + "regionWesternAsia": "Westasien", + "regionEurope": "Europa", + "regionEasternEurope": "Osteuropa", + "regionNorthernEurope": "Nordeuropa", + "regionSouthernEurope": "Südeuropa", + "regionWesternEurope": "Westeuropa", + "regionOceania": "Ozeanien", + "regionAustraliaAndNewZealand": "Australien und Neuseeland", + "regionMelanesia": "Melanesien", + "regionMicronesia": "Mikronesien", + "regionPolynesia": "Polynesien", "managedSelfHosted": { "title": "Verwaltetes Selbsthosted", "description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen", @@ -1937,6 +2030,25 @@ "invalidValue": "Ungültiger Wert", "idpTypeLabel": "Identitätsanbietertyp", "roleMappingExpressionPlaceholder": "z. B. enthalten(Gruppen, 'admin') && 'Admin' || 'Mitglied'", + "roleMappingModeFixedRoles": "Feste Rollen", + "roleMappingModeMappingBuilder": "Mapping Builder", + "roleMappingModeRawExpression": "Roher Ausdruck", + "roleMappingFixedRolesPlaceholderSelect": "Wählen Sie eine oder mehrere Rollen", + "roleMappingFixedRolesPlaceholderFreeform": "Rollennamen eingeben (exakte Übereinstimmung pro Organisation)", + "roleMappingFixedRolesDescriptionSameForAll": "Weisen Sie jedem auto-provisionierten Benutzer die gleiche Rolle zu.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "Für Standardrichtlinien geben Sie Rollennamen ein, die in jeder Organisation existieren, in der Benutzer angegeben sind. Namen müssen exakt übereinstimmen.", + "roleMappingClaimPath": "Pfad einfordern", + "roleMappingClaimPathPlaceholder": "gruppen", + "roleMappingClaimPathDescription": "Pfad in der Token Payload mit Quellwerten (zum Beispiel Gruppen).", + "roleMappingMatchValue": "Match-Wert", + "roleMappingAssignRoles": "Rollen zuweisen", + "roleMappingAddMappingRule": "Zuordnungsregel hinzufügen", + "roleMappingRawExpressionResultDescription": "Ausdruck muss zu einem String oder String Array ausgewertet werden.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Ausdruck muss zu einem String (einem einzigen Rollennamen) ausgewertet werden.", + "roleMappingMatchValuePlaceholder": "Match-Wert (z. B.: Admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Rollennamen eingeben (exakt pro Ort)", + "roleMappingBuilderFreeformRowHint": "Rollennamen müssen mit einer Rolle in jeder Zielorganisation übereinstimmen.", + "roleMappingRemoveRule": "Entfernen", "idpGoogleConfiguration": "Google-Konfiguration", "idpGoogleConfigurationDescription": "Google OAuth2 Zugangsdaten konfigurieren", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Wie lange Zugriffsprotokolle beibehalten werden sollen", "logRetentionActionLabel": "Aktionsprotokoll-Speicherung", "logRetentionActionDescription": "Dauer des Action-Logs", + "logRetentionConnectionLabel": "Verbindungsprotokoll-Speicherung", + "logRetentionConnectionDescription": "Wie lange Verbindungsprotokolle gespeichert werden sollen", "logRetentionDisabled": "Deaktiviert", "logRetention3Days": "3 Tage", "logRetention7Days": "7 Tage", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Ende des folgenden Jahres", "actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen", "accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen", + "connectionLogs": "Verbindungsprotokolle", + "connectionLogsDescription": "Verbindungsprotokolle für Tunnel in dieser Organisation anzeigen", + "sidebarLogsConnection": "Verbindungsprotokolle", + "sidebarLogsStreaming": "Streaming", + "sourceAddress": "Quelladresse", + "destinationAddress": "Zieladresse", + "duration": "Dauer", "licenseRequiredToUse": "Eine Enterprise Edition Lizenz oder Pangolin Cloud wird benötigt, um diese Funktion nutzen zu können. Buchen Sie eine Demo oder POC Testversion.", "ossEnterpriseEditionRequired": "Die Enterprise Edition wird benötigt, um diese Funktion nutzen zu können. Diese Funktion ist auch in Pangolin Cloudverfügbar. Buchen Sie eine Demo oder POC Testversion.", "certResolver": "Zertifikatsauflöser", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Bearbeite eine Rolle und aktiviere die Option 'Gerätegenehmigung erforderlich'. Benutzer mit dieser Rolle benötigen Administrator-Genehmigung für neue Geräte.", "approvalsEmptyStatePreviewDescription": "Vorschau: Wenn aktiviert, werden ausstehende Geräteanfragen hier zur Überprüfung angezeigt", "approvalsEmptyStateButtonText": "Rollen verwalten", - "domainErrorTitle": "Wir haben Probleme mit der Überprüfung deiner Domain" + "domainErrorTitle": "Wir haben Probleme mit der Überprüfung deiner Domain", + "idpAdminAutoProvisionPoliciesTabHint": "Konfigurieren Sie Rollenzuordnungs- und Organisationsrichtlinien auf der Registerkarte Auto-Bereitstellungseinstellungen.", + "streamingTitle": "Event Streaming", + "streamingDescription": "Streamen Sie Events aus Ihrem Unternehmen in Echtzeit zu externen Zielen.", + "streamingUnnamedDestination": "Unbenanntes Ziel", + "streamingNoUrlConfigured": "Keine URL konfiguriert", + "streamingAddDestination": "Ziel hinzufügen", + "streamingHttpWebhookTitle": "HTTP Webhook", + "streamingHttpWebhookDescription": "Sende Ereignisse an jeden HTTP-Endpunkt mit flexibler Authentifizierung und Vorlage.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Streame Ereignisse in eine S3-kompatible Objekt-Speicher-Eimer. Kommt bald.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Events direkt an Ihr Datadog Konto weiterleiten. Kommen Sie bald.", + "streamingTypePickerDescription": "Wählen Sie einen Zieltyp aus, um loszulegen.", + "streamingFailedToLoad": "Fehler beim Laden der Ziele", + "streamingUnexpectedError": "Ein unerwarteter Fehler ist aufgetreten.", + "streamingFailedToUpdate": "Fehler beim Aktualisieren des Ziels", + "streamingDeletedSuccess": "Ziel erfolgreich gelöscht", + "streamingFailedToDelete": "Fehler beim Löschen des Ziels", + "streamingDeleteTitle": "Ziel löschen", + "streamingDeleteButtonText": "Ziel löschen", + "streamingDeleteDialogAreYouSure": "Sind Sie sicher, dass Sie löschen möchten", + "streamingDeleteDialogThisDestination": "dieses Ziel", + "streamingDeleteDialogPermanentlyRemoved": "? Alle Konfiguration wird dauerhaft entfernt.", + "httpDestEditTitle": "Ziel bearbeiten", + "httpDestAddTitle": "HTTP-Ziel hinzufügen", + "httpDestEditDescription": "Aktualisiere die Konfiguration für dieses HTTP-Streaming-Ziel.", + "httpDestAddDescription": "Konfigurieren Sie einen neuen HTTP-Endpunkt, um die Ereignisse Ihrer Organisation zu empfangen.", + "httpDestTabSettings": "Einstellungen", + "httpDestTabHeaders": "Kopfzeilen", + "httpDestTabBody": "Körper", + "httpDestTabLogs": "Logs", + "httpDestNamePlaceholder": "Mein HTTP-Ziel", + "httpDestUrlLabel": "Ziel-URL", + "httpDestUrlErrorHttpRequired": "URL muss http oder https verwenden", + "httpDestUrlErrorHttpsRequired": "HTTPS wird für Cloud-Deployment benötigt", + "httpDestUrlErrorInvalid": "Geben Sie eine gültige URL ein (z.B. https://example.com/webhook)", + "httpDestAuthTitle": "Authentifizierung", + "httpDestAuthDescription": "Legen Sie fest, wie Anfragen an Ihren Endpunkt authentifiziert werden.", + "httpDestAuthNoneTitle": "Keine Authentifizierung", + "httpDestAuthNoneDescription": "Sendet Anfragen ohne Autorisierungs-Header.", + "httpDestAuthBearerTitle": "Bären-Token", + "httpDestAuthBearerDescription": "Fügt eine Berechtigung hinzu: Bearer Header zu jeder Anfrage.", + "httpDestAuthBearerPlaceholder": "Ihr API-Schlüssel oder Token", + "httpDestAuthBasicTitle": "Einfacher Auth", + "httpDestAuthBasicDescription": "Fügt eine Autorisierung hinzu: Basic Kopfzeile hinzu. Geben Sie Anmeldedaten als Benutzername:password an.", + "httpDestAuthBasicPlaceholder": "benutzername:password", + "httpDestAuthCustomTitle": "Eigene Kopfzeile", + "httpDestAuthCustomDescription": "Geben Sie einen eigenen HTTP-Header-Namen und einen Wert für die Authentifizierung an (z.B. X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Headername (z.B. X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Header-Wert", + "httpDestCustomHeadersTitle": "Eigene HTTP-Header", + "httpDestCustomHeadersDescription": "Fügen Sie jeder ausgehenden Anfrage benutzerdefinierte Kopfzeilen hinzu. Nützlich für statische Tokens oder einen benutzerdefinierten Content-Typ. Standardmäßig wird Content-Type: application/json gesendet.", + "httpDestNoHeadersConfigured": "Keine benutzerdefinierten Header konfiguriert. Klicken Sie auf \"Header hinzufügen\", um einen hinzuzufügen.", + "httpDestHeaderNamePlaceholder": "Header-Name", + "httpDestHeaderValuePlaceholder": "Wert", + "httpDestAddHeader": "Header hinzufügen", + "httpDestBodyTemplateTitle": "Eigene Body-Vorlage", + "httpDestBodyTemplateDescription": "Steuere die JSON-Payload-Struktur, die an deinen Endpunkt gesendet wurde. Wenn deaktiviert, wird für jede Veranstaltung ein Standard-JSON-Objekt gesendet.", + "httpDestEnableBodyTemplate": "Eigene Körpervorlage aktivieren", + "httpDestBodyTemplateLabel": "Body-Vorlage (JSON)", + "httpDestBodyTemplateHint": "Verwenden Sie Template-Variablen, um Ereignisfelder in Ihrer Payload zu referenzieren.", + "httpDestPayloadFormatTitle": "Payload-Format", + "httpDestPayloadFormatDescription": "Wie Ereignisse in jedes Anfragegremium serialisiert werden.", + "httpDestFormatJsonArrayTitle": "JSON Array", + "httpDestFormatJsonArrayDescription": "Eine Anfrage pro Stapel ist ein JSON-Array. Kompatibel mit den meisten generischen Webhooks und Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Eine Anfrage pro Batch, der Körper ist newline-getrenntes JSON — ein Objekt pro Zeile, kein äußeres Array. Benötigt von Splunk HEC, Elastic / OpenSearch, und Grafana Loki.", + "httpDestFormatSingleTitle": "Ein Ereignis pro Anfrage", + "httpDestFormatSingleDescription": "Sendet eine separate HTTP-POST für jedes einzelne Ereignis. Nur für Endpunkte, die Batches nicht handhaben können.", + "httpDestLogTypesTitle": "Log-Typen", + "httpDestLogTypesDescription": "Wählen Sie, welche Log-Typen an dieses Ziel weitergeleitet werden. Nur aktivierte Log-Typen werden gestreamt.", + "httpDestAccessLogsTitle": "Zugriffsprotokolle", + "httpDestAccessLogsDescription": "Ressourcenzugriffe, einschließlich authentifizierter und abgelehnter Anfragen.", + "httpDestActionLogsTitle": "Aktionsprotokolle", + "httpDestActionLogsDescription": "Administrative Maßnahmen, die von Benutzern innerhalb der Organisation durchgeführt werden.", + "httpDestConnectionLogsTitle": "Verbindungsprotokolle", + "httpDestConnectionLogsDescription": "Site- und Tunnelverbindungen, einschließlich Verbindungen und Trennungen.", + "httpDestRequestLogsTitle": "Logs anfordern", + "httpDestRequestLogsDescription": "HTTP-Request-Protokolle für proxiierte Ressourcen, einschließlich Methode, Pfad und Antwort-Code.", + "httpDestSaveChanges": "Änderungen speichern", + "httpDestCreateDestination": "Ziel erstellen", + "httpDestUpdatedSuccess": "Ziel erfolgreich aktualisiert", + "httpDestCreatedSuccess": "Ziel erfolgreich erstellt", + "httpDestUpdateFailed": "Fehler beim Aktualisieren des Ziels", + "httpDestCreateFailed": "Fehler beim Erstellen des Ziels" } diff --git a/messages/en-US.json b/messages/en-US.json index 478b64d44..1edd323c4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -148,6 +148,11 @@ "createLink": "Create Link", "resourcesNotFound": "No resources found", "resourceSearch": "Search resources", + "machineSearch": "Search machines", + "machinesSearch": "Search machine clients...", + "machineNotFound": "No machines found", + "userDeviceSearch": "Search user devices", + "userDevicesSearch": "Search user devices...", "openMenu": "Open menu", "resource": "Resource", "title": "Title", @@ -323,6 +328,54 @@ "apiKeysDelete": "Delete API Key", "apiKeysManage": "Manage API Keys", "apiKeysDescription": "API keys are used to authenticate with the integration API", + "provisioningKeysTitle": "Provisioning Key", + "provisioningKeysManage": "Manage Provisioning Keys", + "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", + "provisioningManage": "Provisioning", + "provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.", + "pendingSites": "Pending Sites", + "siteApproveSuccess": "Site approved successfully", + "siteApproveError": "Error approving site", + "provisioningKeys": "Provisioning Keys", + "searchProvisioningKeys": "Search provisioning keys...", + "provisioningKeysAdd": "Generate Provisioning Key", + "provisioningKeysErrorDelete": "Error deleting provisioning key", + "provisioningKeysErrorDeleteMessage": "Error deleting provisioning key", + "provisioningKeysQuestionRemove": "Are you sure you want to remove this provisioning key from the organization?", + "provisioningKeysMessageRemove": "Once removed, the key can no longer be used for site provisioning.", + "provisioningKeysDeleteConfirm": "Confirm Delete Provisioning Key", + "provisioningKeysDelete": "Delete Provisioning key", + "provisioningKeysCreate": "Generate Provisioning Key", + "provisioningKeysCreateDescription": "Generate a new provisioning key for the organization", + "provisioningKeysSeeAll": "See all provisioning keys", + "provisioningKeysSave": "Save the provisioning key", + "provisioningKeysSaveDescription": "You will only be able to see this once. Copy it to a secure place.", + "provisioningKeysErrorCreate": "Error creating provisioning key", + "provisioningKeysList": "New provisioning key", + "provisioningKeysMaxBatchSize": "Max batch size", + "provisioningKeysUnlimitedBatchSize": "Unlimited batch size (no limit)", + "provisioningKeysMaxBatchUnlimited": "Unlimited", + "provisioningKeysMaxBatchSizeInvalid": "Enter a valid max batch size (1–1,000,000).", + "provisioningKeysValidUntil": "Valid until", + "provisioningKeysValidUntilHint": "Leave empty for no expiration.", + "provisioningKeysValidUntilInvalid": "Enter a valid date and time.", + "provisioningKeysNumUsed": "Times used", + "provisioningKeysLastUsed": "Last used", + "provisioningKeysNoExpiry": "No expiration", + "provisioningKeysNeverUsed": "Never", + "provisioningKeysEdit": "Edit Provisioning Key", + "provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.", + "provisioningKeysApproveNewSites": "Approve new sites", + "provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.", + "provisioningKeysUpdateError": "Error updating provisioning key", + "provisioningKeysUpdated": "Provisioning key updated", + "provisioningKeysUpdatedDescription": "Your changes have been saved.", + "provisioningKeysBannerTitle": "Site Provisioning Keys", + "provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup — no need to set up separate credentials for each site.", + "provisioningKeysBannerButtonText": "Learn More", + "pendingSitesBannerTitle": "Pending Sites", + "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review. Approve each site before it becomes active and gains access to your resources.", + "pendingSitesBannerButtonText": "Learn More", "apiKeysSettings": "{apiKeyName} Settings", "userTitle": "Manage All Users", "userDescription": "View and manage all users in the system", @@ -509,9 +562,12 @@ "userSaved": "User saved", "userSavedDescription": "The user has been updated.", "autoProvisioned": "Auto Provisioned", + "autoProvisionSettings": "Auto Provision Settings", "autoProvisionedDescription": "Allow this user to be automatically managed by identity provider", "accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsSubmit": "Save Access Controls", + "singleRolePerUserPlanNotice": "Your plan only supports one role per user.", + "singleRolePerUserEditionNotice": "This edition only supports one role per user.", "roles": "Roles", "accessUsersRoles": "Manage Users & Roles", "accessUsersRolesDescription": "Invite users and add them to roles to manage access to the organization", @@ -889,7 +945,7 @@ "defaultMappingsRole": "Default Role Mapping", "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Default Organization Mapping", - "defaultMappingsOrgDescription": "This expression must return the org ID or true for the user to be allowed to access the organization.", + "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining an organization policy for that org is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", "defaultMappingsSubmit": "Save Default Mappings", "orgPoliciesEdit": "Edit Organization Policy", "org": "Organization", @@ -1042,7 +1098,6 @@ "pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.", "overview": "Overview", "home": "Home", - "accessControl": "Access Control", "settings": "Settings", "usersAll": "All Users", "license": "License", @@ -1152,6 +1207,7 @@ "actionRemoveUser": "Remove User", "actionListUsers": "List Users", "actionAddUserRole": "Add User Role", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Generate Access Token", "actionDeleteAccessToken": "Delete Access Token", "actionListAccessTokens": "List Access Tokens", @@ -1268,6 +1324,7 @@ "sidebarRoles": "Roles", "sidebarShareableLinks": "Links", "sidebarApiKeys": "API Keys", + "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", "sidebarAllUsers": "All Users", "sidebarIdentityProviders": "Identity Providers", @@ -1893,6 +1950,40 @@ "exitNode": "Exit Node", "country": "Country", "rulesMatchCountry": "Currently based on source IP", + "region": "Region", + "selectRegion": "Select region", + "searchRegions": "Search regions...", + "noRegionFound": "No region found.", + "rulesMatchRegion": "Select a regional grouping of countries", + "rulesErrorInvalidRegion": "Invalid region", + "rulesErrorInvalidRegionDescription": "Please select a valid region.", + "regionAfrica": "Africa", + "regionNorthernAfrica": "Northern Africa", + "regionEasternAfrica": "Eastern Africa", + "regionMiddleAfrica": "Middle Africa", + "regionSouthernAfrica": "Southern Africa", + "regionWesternAfrica": "Western Africa", + "regionAmericas": "Americas", + "regionCaribbean": "Caribbean", + "regionCentralAmerica": "Central America", + "regionSouthAmerica": "South America", + "regionNorthernAmerica": "Northern America", + "regionAsia": "Asia", + "regionCentralAsia": "Central Asia", + "regionEasternAsia": "Eastern Asia", + "regionSouthEasternAsia": "South-Eastern Asia", + "regionSouthernAsia": "Southern Asia", + "regionWesternAsia": "Western Asia", + "regionEurope": "Europe", + "regionEasternEurope": "Eastern Europe", + "regionNorthernEurope": "Northern Europe", + "regionSouthernEurope": "Southern Europe", + "regionWesternEurope": "Western Europe", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Australia and New Zealand", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "Managed Self-Hosted", "description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles", @@ -1941,6 +2032,25 @@ "invalidValue": "Invalid value", "idpTypeLabel": "Identity Provider Type", "roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'", + "roleMappingModeFixedRoles": "Fixed Roles", + "roleMappingModeMappingBuilder": "Mapping Builder", + "roleMappingModeRawExpression": "Raw Expression", + "roleMappingFixedRolesPlaceholderSelect": "Select one or more roles", + "roleMappingFixedRolesPlaceholderFreeform": "Type role names (exact match per organization)", + "roleMappingFixedRolesDescriptionSameForAll": "Assign the same role set to every auto-provisioned user.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "For default policies, type role names that exist in each organization where users are provisioned. Names must match exactly.", + "roleMappingClaimPath": "Claim Path", + "roleMappingClaimPathPlaceholder": "groups", + "roleMappingClaimPathDescription": "Path in the token payload that contains source values (for example, groups).", + "roleMappingMatchValue": "Match Value", + "roleMappingAssignRoles": "Assign Roles", + "roleMappingAddMappingRule": "Add Mapping Rule", + "roleMappingRawExpressionResultDescription": "Expression must evaluate to a string or string array.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Expression must evaluate to a string (a single role name).", + "roleMappingMatchValuePlaceholder": "Match value (for example: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)", + "roleMappingBuilderFreeformRowHint": "Role names must match a role in each target organization.", + "roleMappingRemoveRule": "Remove", "idpGoogleConfiguration": "Google Configuration", "idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2337,6 +2447,8 @@ "logRetentionAccessDescription": "How long to retain access logs", "logRetentionActionLabel": "Action Log Retention", "logRetentionActionDescription": "How long to retain action logs", + "logRetentionConnectionLabel": "Connection Log Retention", + "logRetentionConnectionDescription": "How long to retain connection logs", "logRetentionDisabled": "Disabled", "logRetention3Days": "3 days", "logRetention7Days": "7 days", @@ -2347,8 +2459,15 @@ "logRetentionEndOfFollowingYear": "End of following year", "actionLogsDescription": "View a history of actions performed in this organization", "accessLogsDescription": "View access auth requests for resources in this organization", - "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a demo or POC trial.", - "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a demo or POC trial.", + "connectionLogs": "Connection Logs", + "connectionLogsDescription": "View connection logs for tunnels in this organization", + "sidebarLogsConnection": "Connection Logs", + "sidebarLogsStreaming": "Streaming", + "sourceAddress": "Source Address", + "destinationAddress": "Destination Address", + "duration": "Duration", + "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a free demo or POC trial to learn more.", + "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a free demo or POC trial to learn more.", "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", @@ -2513,9 +2632,9 @@ "remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?", "remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.", "agent": "Agent", - "personalUseOnly": "Personal Use Only", - "loginPageLicenseWatermark": "This instance is licensed for personal use only.", - "instanceIsUnlicensed": "This instance is unlicensed.", + "personalUseOnly": "Personal Use Only", + "loginPageLicenseWatermark": "This instance is licensed for personal use only.", + "instanceIsUnlicensed": "This instance is unlicensed.", "portRestrictions": "Port Restrictions", "allPorts": "All", "custom": "Custom", @@ -2569,7 +2688,7 @@ "automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.", "forced": "Forced", "forcedModeDescription": "Always show the maintenance page regardless of backend health. Use this for planned maintenance when you want to prevent all access.", - "warning:" : "Warning:", + "warning:": "Warning:", "forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.", "pageTitle": "Page Title", "pageTitleDescription": "The main heading displayed on the maintenance page", @@ -2686,5 +2805,90 @@ "approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.", "approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review", "approvalsEmptyStateButtonText": "Manage Roles", - "domainErrorTitle": "We are having trouble verifying your domain" + "domainErrorTitle": "We are having trouble verifying your domain", + "idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the Auto Provision Settings tab.", + "streamingTitle": "Event Streaming", + "streamingDescription": "Stream events from your organization to external destinations in real time.", + "streamingUnnamedDestination": "Unnamed destination", + "streamingNoUrlConfigured": "No URL configured", + "streamingAddDestination": "Add Destination", + "streamingHttpWebhookTitle": "HTTP Webhook", + "streamingHttpWebhookDescription": "Send events to any HTTP endpoint with flexible authentication and templating.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Stream events to an S3-compatible object storage bucket. Coming soon.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Forward events directly to your Datadog account. Coming soon.", + "streamingTypePickerDescription": "Choose a destination type to get started.", + "streamingFailedToLoad": "Failed to load destinations", + "streamingUnexpectedError": "An unexpected error occurred.", + "streamingFailedToUpdate": "Failed to update destination", + "streamingDeletedSuccess": "Destination deleted successfully", + "streamingFailedToDelete": "Failed to delete destination", + "streamingDeleteTitle": "Delete Destination", + "streamingDeleteButtonText": "Delete Destination", + "streamingDeleteDialogAreYouSure": "Are you sure you want to delete", + "streamingDeleteDialogThisDestination": "this destination", + "streamingDeleteDialogPermanentlyRemoved": "? All configuration will be permanently removed.", + "httpDestEditTitle": "Edit Destination", + "httpDestAddTitle": "Add HTTP Destination", + "httpDestEditDescription": "Update the configuration for this HTTP event streaming destination.", + "httpDestAddDescription": "Configure a new HTTP endpoint to receive your organization's events.", + "httpDestTabSettings": "Settings", + "httpDestTabHeaders": "Headers", + "httpDestTabBody": "Body", + "httpDestTabLogs": "Logs", + "httpDestNamePlaceholder": "My HTTP destination", + "httpDestUrlLabel": "Destination URL", + "httpDestUrlErrorHttpRequired": "URL must use http or https", + "httpDestUrlErrorHttpsRequired": "HTTPS is required on cloud deployments", + "httpDestUrlErrorInvalid": "Enter a valid URL (e.g. https://example.com/webhook)", + "httpDestAuthTitle": "Authentication", + "httpDestAuthDescription": "Choose how requests to your endpoint are authenticated.", + "httpDestAuthNoneTitle": "No Authentication", + "httpDestAuthNoneDescription": "Sends requests without an Authorization header.", + "httpDestAuthBearerTitle": "Bearer Token", + "httpDestAuthBearerDescription": "Adds an Authorization: Bearer header to each request.", + "httpDestAuthBearerPlaceholder": "Your API key or token", + "httpDestAuthBasicTitle": "Basic Auth", + "httpDestAuthBasicDescription": "Adds an Authorization: Basic header. Provide credentials as username:password.", + "httpDestAuthBasicPlaceholder": "username:password", + "httpDestAuthCustomTitle": "Custom Header", + "httpDestAuthCustomDescription": "Specify a custom HTTP header name and value for authentication (e.g. X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Header name (e.g. X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Header value", + "httpDestCustomHeadersTitle": "Custom HTTP Headers", + "httpDestCustomHeadersDescription": "Add custom headers to every outgoing request. Useful for static tokens or a custom Content-Type. By default, Content-Type: application/json is sent.", + "httpDestNoHeadersConfigured": "No custom headers configured. Click \"Add Header\" to add one.", + "httpDestHeaderNamePlaceholder": "Header name", + "httpDestHeaderValuePlaceholder": "Value", + "httpDestAddHeader": "Add Header", + "httpDestBodyTemplateTitle": "Custom Body Template", + "httpDestBodyTemplateDescription": "Control the JSON payload structure sent to your endpoint. If disabled, a default JSON object is sent for each event.", + "httpDestEnableBodyTemplate": "Enable custom body template", + "httpDestBodyTemplateLabel": "Body Template (JSON)", + "httpDestBodyTemplateHint": "Use template variables to reference event fields in your payload.", + "httpDestPayloadFormatTitle": "Payload Format", + "httpDestPayloadFormatDescription": "How events are serialised into each request body.", + "httpDestFormatJsonArrayTitle": "JSON Array", + "httpDestFormatJsonArrayDescription": "One request per batch, body is a JSON array. Compatible with most generic webhooks and Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "One request per batch, body is newline-delimited JSON — one object per line, no outer array. Required by Splunk HEC, Elastic / OpenSearch, and Grafana Loki.", + "httpDestFormatSingleTitle": "One Event Per Request", + "httpDestFormatSingleDescription": "Sends a separate HTTP POST for each individual event. Use only for endpoints that cannot handle batches.", + "httpDestLogTypesTitle": "Log Types", + "httpDestLogTypesDescription": "Choose which log types are forwarded to this destination. Only enabled log types will be streamed.", + "httpDestAccessLogsTitle": "Access Logs", + "httpDestAccessLogsDescription": "Resource access attempts, including authenticated and denied requests.", + "httpDestActionLogsTitle": "Action Logs", + "httpDestActionLogsDescription": "Administrative actions performed by users within the organization.", + "httpDestConnectionLogsTitle": "Connection Logs", + "httpDestConnectionLogsDescription": "Site and tunnel connection events, including connects and disconnects.", + "httpDestRequestLogsTitle": "Request Logs", + "httpDestRequestLogsDescription": "HTTP request logs for proxied resources, including method, path, and response code.", + "httpDestSaveChanges": "Save Changes", + "httpDestCreateDestination": "Create Destination", + "httpDestUpdatedSuccess": "Destination updated successfully", + "httpDestCreatedSuccess": "Destination created successfully", + "httpDestUpdateFailed": "Failed to update destination", + "httpDestCreateFailed": "Failed to create destination" } diff --git a/messages/es-ES.json b/messages/es-ES.json index e33a85ace..39a2919f8 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -148,6 +148,11 @@ "createLink": "Crear enlace", "resourcesNotFound": "No se encontraron recursos", "resourceSearch": "Buscar recursos", + "machineSearch": "Buscar máquinas", + "machinesSearch": "Buscar clientes...", + "machineNotFound": "No hay máquinas", + "userDeviceSearch": "Buscar dispositivos de usuario", + "userDevicesSearch": "Buscar dispositivos de usuario...", "openMenu": "Abrir menú", "resource": "Recurso", "title": "Título", @@ -323,6 +328,54 @@ "apiKeysDelete": "Borrar Clave API", "apiKeysManage": "Administrar claves API", "apiKeysDescription": "Las claves API se utilizan para autenticar con la API de integración", + "provisioningKeysTitle": "Clave de aprovisionamiento", + "provisioningKeysManage": "Administrar Claves de Aprovisionamiento", + "provisioningKeysDescription": "Las claves de aprovisionamiento se utilizan para autenticar la provisión automatizada del sitio para su organización.", + "provisioningManage": "Aprovisionamiento", + "provisioningDescription": "Administrar las claves de aprovisionamiento y revisar los sitios pendientes de aprobación.", + "pendingSites": "Sitios pendientes", + "siteApproveSuccess": "Sitio aprobado con éxito", + "siteApproveError": "Error al aprobar el sitio", + "provisioningKeys": "Claves de aprovisionamiento", + "searchProvisioningKeys": "Buscar claves de suministro...", + "provisioningKeysAdd": "Generar clave de aprovisionamiento", + "provisioningKeysErrorDelete": "Error al eliminar la clave de aprovisionamiento", + "provisioningKeysErrorDeleteMessage": "Error al eliminar la clave de aprovisionamiento", + "provisioningKeysQuestionRemove": "¿Está seguro que desea eliminar esta clave de aprovisionamiento de la organización?", + "provisioningKeysMessageRemove": "Una vez eliminada, la clave ya no se puede utilizar para la disposición del sitio.", + "provisioningKeysDeleteConfirm": "Confirmar Eliminar Clave de Aprovisionamiento", + "provisioningKeysDelete": "Eliminar clave de aprovisionamiento", + "provisioningKeysCreate": "Generar clave de aprovisionamiento", + "provisioningKeysCreateDescription": "Generar una nueva clave de aprovisionamiento para la organización", + "provisioningKeysSeeAll": "Ver todas las claves de aprovisionamiento", + "provisioningKeysSave": "Guardar la clave de aprovisionamiento", + "provisioningKeysSaveDescription": "Sólo podrás verlo una vez. Copítalo a un lugar seguro.", + "provisioningKeysErrorCreate": "Error al crear la clave de provisioning", + "provisioningKeysList": "Nueva clave de aprovisionamiento", + "provisioningKeysMaxBatchSize": "Tamaño máximo de lote", + "provisioningKeysUnlimitedBatchSize": "Tamaño ilimitado del lote (sin límite)", + "provisioningKeysMaxBatchUnlimited": "Ilimitado", + "provisioningKeysMaxBatchSizeInvalid": "Introduzca un tamaño máximo de lote válido (1–1,000,000).", + "provisioningKeysValidUntil": "Válido hasta", + "provisioningKeysValidUntilHint": "Dejar vacío para no expirar.", + "provisioningKeysValidUntilInvalid": "Introduzca una fecha y hora válidas.", + "provisioningKeysNumUsed": "Tiempos usados", + "provisioningKeysLastUsed": "Último uso", + "provisioningKeysNoExpiry": "No expiración", + "provisioningKeysNeverUsed": "Nunca", + "provisioningKeysEdit": "Editar clave de aprovisionamiento", + "provisioningKeysEditDescription": "Actualizar el tamaño máximo de lote y el tiempo de caducidad para esta clave.", + "provisioningKeysApproveNewSites": "Aprobar nuevos sitios", + "provisioningKeysApproveNewSitesDescription": "Aprobar automáticamente los sitios que se registran con esta clave.", + "provisioningKeysUpdateError": "Error al actualizar la clave de aprovisionamiento", + "provisioningKeysUpdated": "Clave de aprovisionamiento actualizada", + "provisioningKeysUpdatedDescription": "Sus cambios han sido guardados.", + "provisioningKeysBannerTitle": "Claves de aprovisionamiento del sitio", + "provisioningKeysBannerDescription": "Generar una clave de aprovisionamiento y usarla con el conector Newt para crear automáticamente sitios en el primer inicio — no es necesario configurar credenciales separadas para cada sitio.", + "provisioningKeysBannerButtonText": "Saber más", + "pendingSitesBannerTitle": "Sitios pendientes", + "pendingSitesBannerDescription": "Los sitios que se conectan usando una clave de aprovisionamiento aparecen aquí para su revisión. Aprobar cada sitio antes de que se active y obtenga acceso a sus recursos.", + "pendingSitesBannerButtonText": "Saber más", "apiKeysSettings": "Ajustes {apiKeyName}", "userTitle": "Administrar todos los usuarios", "userDescription": "Ver y administrar todos los usuarios en el sistema", @@ -509,9 +562,12 @@ "userSaved": "Usuario guardado", "userSavedDescription": "El usuario ha sido actualizado.", "autoProvisioned": "Auto asegurado", + "autoProvisionSettings": "Configuración de Auto Provision", "autoProvisionedDescription": "Permitir a este usuario ser administrado automáticamente por el proveedor de identidad", "accessControlsDescription": "Administrar lo que este usuario puede acceder y hacer en la organización", "accessControlsSubmit": "Guardar controles de acceso", + "singleRolePerUserPlanNotice": "Tu plan sólo soporta un rol por usuario.", + "singleRolePerUserEditionNotice": "Esta edición sólo soporta un rol por usuario.", "roles": "Roles", "accessUsersRoles": "Administrar usuarios y roles", "accessUsersRolesDescription": "Invitar usuarios y añadirlos a roles para administrar el acceso a la organización", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.", "setupTokenRequired": "Se requiere el token de configuración", "actionUpdateSite": "Actualizar sitio", + "actionResetSiteBandwidth": "Restablecer ancho de banda de la organización", "actionListSiteRoles": "Lista de roles permitidos del sitio", "actionCreateResource": "Crear Recurso", "actionDeleteResource": "Eliminar Recurso", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Eliminar usuario", "actionListUsers": "Listar usuarios", "actionAddUserRole": "Añadir rol de usuario", + "actionSetUserOrgRoles": "Establecer roles de usuario", "actionGenerateAccessToken": "Generar token de acceso", "actionDeleteAccessToken": "Eliminar token de acceso", "actionListAccessTokens": "Lista de Tokens de Acceso", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Roles", "sidebarShareableLinks": "Enlaces", "sidebarApiKeys": "Claves API", + "sidebarProvisioning": "Aprovisionamiento", "sidebarSettings": "Ajustes", "sidebarAllUsers": "Todos los usuarios", "sidebarIdentityProviders": "Proveedores de identidad", @@ -1889,6 +1948,40 @@ "exitNode": "Nodo de Salida", "country": "País", "rulesMatchCountry": "Actualmente basado en IP de origen", + "region": "Región", + "selectRegion": "Seleccionar región", + "searchRegions": "Buscar regiones...", + "noRegionFound": "Región no encontrada.", + "rulesMatchRegion": "Seleccione una agrupación regional de países", + "rulesErrorInvalidRegion": "Región no válida", + "rulesErrorInvalidRegionDescription": "Por favor, seleccione una región válida.", + "regionAfrica": "Africa", + "regionNorthernAfrica": "África septentrional", + "regionEasternAfrica": "África oriental", + "regionMiddleAfrica": "África central", + "regionSouthernAfrica": "África del Sur", + "regionWesternAfrica": "África Occidental", + "regionAmericas": "Américas", + "regionCaribbean": "Caribe", + "regionCentralAmerica": "América Central", + "regionSouthAmerica": "América del Sur", + "regionNorthernAmerica": "América del Norte", + "regionAsia": "Asia", + "regionCentralAsia": "Asia Central", + "regionEasternAsia": "Asia oriental", + "regionSouthEasternAsia": "Asia sudoriental", + "regionSouthernAsia": "Asia meridional", + "regionWesternAsia": "Asia Occidental", + "regionEurope": "Europa", + "regionEasternEurope": "Europa del Este", + "regionNorthernEurope": "Europa septentrional", + "regionSouthernEurope": "Europa meridional", + "regionWesternEurope": "Europa Occidental", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Australia y Nueva Zelanda", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "Autogestionado", "description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra", @@ -1937,6 +2030,25 @@ "invalidValue": "Valor inválido", "idpTypeLabel": "Tipo de proveedor de identidad", "roleMappingExpressionPlaceholder": "e.g., contiene(grupos, 'administrador') && 'administrador' || 'miembro'", + "roleMappingModeFixedRoles": "Roles fijos", + "roleMappingModeMappingBuilder": "Constructor de mapeo", + "roleMappingModeRawExpression": "Expresión sin procesar", + "roleMappingFixedRolesPlaceholderSelect": "Seleccione uno o más roles", + "roleMappingFixedRolesPlaceholderFreeform": "Nombre de rol de tipo (coincidencia exacta por organización)", + "roleMappingFixedRolesDescriptionSameForAll": "Asignar el mismo rol establecido a cada usuario auto-provisionado.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "Para las políticas predeterminadas, escriba nombres de roles que existen en cada organización donde los usuarios son proporcionados. Los nombres deben coincidir exactamente.", + "roleMappingClaimPath": "Reclamar ruta", + "roleMappingClaimPathPlaceholder": "grupos", + "roleMappingClaimPathDescription": "Ruta en el payload del token que contiene valores de origen (por ejemplo, grupos).", + "roleMappingMatchValue": "Valor de partida", + "roleMappingAssignRoles": "Asignar roles", + "roleMappingAddMappingRule": "Añadir regla de mapeo", + "roleMappingRawExpressionResultDescription": "La expresión debe evaluar a un array de cadenas o cadenas.", + "roleMappingRawExpressionResultDescriptionSingleRole": "La expresión debe evaluar una cadena (un solo nombre de rol).", + "roleMappingMatchValuePlaceholder": "Valor coincidente (por ejemplo: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Escriba nombres de rol (exacto por org)", + "roleMappingBuilderFreeformRowHint": "Los nombres de rol deben coincidir con un rol en cada organización objetivo.", + "roleMappingRemoveRule": "Eliminar", "idpGoogleConfiguration": "Configuración de Google", "idpGoogleConfigurationDescription": "Configurar las credenciales de Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Cuánto tiempo retener los registros de acceso", "logRetentionActionLabel": "Retención de registro de acción", "logRetentionActionDescription": "Cuánto tiempo retener los registros de acción", + "logRetentionConnectionLabel": "Retención de Registro de Conexión", + "logRetentionConnectionDescription": "Cuánto tiempo conservar los registros de conexión", "logRetentionDisabled": "Deshabilitado", "logRetention3Days": "3 días", "logRetention7Days": "7 días", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Fin del año siguiente", "actionLogsDescription": "Ver un historial de acciones realizadas en esta organización", "accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización", + "connectionLogs": "Registros de conexión", + "connectionLogsDescription": "Ver registros de conexión para túneles en esta organización", + "sidebarLogsConnection": "Registros de conexión", + "sidebarLogsStreaming": "Transmisión", + "sourceAddress": "Dirección de origen", + "destinationAddress": "Dirección de destino", + "duration": "Duración", "licenseRequiredToUse": "Se requiere una licencia Enterprise Edition o Pangolin Cloud para usar esta función. Reserve una demostración o prueba POC.", "ossEnterpriseEditionRequired": "La Enterprise Edition es necesaria para utilizar esta función. Esta función también está disponible en Pangolin Cloud. Reserva una demostración o prueba POC.", "certResolver": "Resolver certificado", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Editar un rol y habilitar la opción 'Requerir aprobaciones de dispositivos'. Los usuarios con este rol necesitarán la aprobación del administrador para nuevos dispositivos.", "approvalsEmptyStatePreviewDescription": "Vista previa: Cuando está habilitado, las solicitudes de dispositivo pendientes aparecerán aquí para su revisión", "approvalsEmptyStateButtonText": "Administrar roles", - "domainErrorTitle": "Estamos teniendo problemas para verificar su dominio" + "domainErrorTitle": "Estamos teniendo problemas para verificar su dominio", + "idpAdminAutoProvisionPoliciesTabHint": "Configure el mapeo de roles y las políticas de organización en la pestaña Configuración de provisión automática.", + "streamingTitle": "Transmisión de Eventos", + "streamingDescription": "Transmita eventos desde su organización a destinos externos en tiempo real.", + "streamingUnnamedDestination": "Destino sin nombre", + "streamingNoUrlConfigured": "No hay URL configurada", + "streamingAddDestination": "Añadir destino", + "streamingHttpWebhookTitle": "Webhook HTTP", + "streamingHttpWebhookDescription": "Enviar eventos a cualquier extremo HTTP con autenticación flexible y plantilla.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Transmite eventos a un bucket de almacenamiento de objetos compatible con S3. Próximamente.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Reenviar eventos directamente a tu cuenta de Datadog. Próximamente.", + "streamingTypePickerDescription": "Elija un tipo de destino para empezar.", + "streamingFailedToLoad": "Error al cargar destinos", + "streamingUnexpectedError": "Se ha producido un error inesperado.", + "streamingFailedToUpdate": "Error al actualizar destino", + "streamingDeletedSuccess": "Destino eliminado correctamente", + "streamingFailedToDelete": "Error al eliminar destino", + "streamingDeleteTitle": "Eliminar destino", + "streamingDeleteButtonText": "Eliminar destino", + "streamingDeleteDialogAreYouSure": "¿Está seguro que desea eliminar", + "streamingDeleteDialogThisDestination": "este destino", + "streamingDeleteDialogPermanentlyRemoved": "? Toda la configuración se eliminará permanentemente.", + "httpDestEditTitle": "Editar destino", + "httpDestAddTitle": "Añadir destino HTTP", + "httpDestEditDescription": "Actualizar la configuración para este destino de transmisión de eventos HTTP.", + "httpDestAddDescription": "Configure un nuevo extremo HTTP para recibir los eventos de su organización.", + "httpDestTabSettings": "Ajustes", + "httpDestTabHeaders": "Encabezados", + "httpDestTabBody": "Cuerpo", + "httpDestTabLogs": "Registros", + "httpDestNamePlaceholder": "Mi destino HTTP", + "httpDestUrlLabel": "URL de destino", + "httpDestUrlErrorHttpRequired": "URL debe usar http o https", + "httpDestUrlErrorHttpsRequired": "HTTPS es necesario en implementaciones en la nube", + "httpDestUrlErrorInvalid": "Introduzca una URL válida (ej. https://example.com/webhook)", + "httpDestAuthTitle": "Autenticación", + "httpDestAuthDescription": "Elija cómo están autenticadas las solicitudes en su punto final.", + "httpDestAuthNoneTitle": "Sin autenticación", + "httpDestAuthNoneDescription": "Envía solicitudes sin un encabezado de autorización.", + "httpDestAuthBearerTitle": "Tóken de portador", + "httpDestAuthBearerDescription": "Añade una autorización: portador encabezado a cada solicitud.", + "httpDestAuthBearerPlaceholder": "Tu clave o token API", + "httpDestAuthBasicTitle": "Auth Básica", + "httpDestAuthBasicDescription": "Añade una Autorización: encabezado básico . Proporcione credenciales como nombre de usuario: contraseña.", + "httpDestAuthBasicPlaceholder": "usuario:contraseña", + "httpDestAuthCustomTitle": "Cabecera personalizada", + "httpDestAuthCustomDescription": "Especifique un nombre de cabecera HTTP personalizado y un valor para la autenticación (por ejemplo, X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Nombre de cabecera (ej. X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Valor de cabecera", + "httpDestCustomHeadersTitle": "Cabeceras HTTP personalizadas", + "httpDestCustomHeadersDescription": "Añadir cabeceras personalizadas a cada petición saliente. Útil para tokens estáticos o un tipo de contenido personalizado. De forma predeterminada, Content Type: application/json es enviado.", + "httpDestNoHeadersConfigured": "No hay cabeceras personalizadas. Haga clic en \"Añadir cabecera\" para añadir una.", + "httpDestHeaderNamePlaceholder": "Nombre de cabecera", + "httpDestHeaderValuePlaceholder": "Valor", + "httpDestAddHeader": "Añadir cabecera", + "httpDestBodyTemplateTitle": "Plantilla de cuerpo personalizada", + "httpDestBodyTemplateDescription": "Controla la estructura de carga de JSON enviada a tu punto final. Si está desactivado, se envía un objeto JSON por defecto para cada evento.", + "httpDestEnableBodyTemplate": "Activar plantilla de cuerpo personalizado", + "httpDestBodyTemplateLabel": "Plantilla de cuerpo (JSON)", + "httpDestBodyTemplateHint": "Utilice variables de plantilla para referenciar los campos del evento en su carga útil.", + "httpDestPayloadFormatTitle": "Formato de carga", + "httpDestPayloadFormatDescription": "Cómo se serializan los eventos en cada cuerpo de solicitud.", + "httpDestFormatJsonArrayTitle": "Matriz JSON", + "httpDestFormatJsonArrayDescription": "Una petición por lote, cuerpo es una matriz JSON. Compatible con la mayoría de los webhooks y Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Una petición por lote, el cuerpo es JSON delimitado por línea — un objeto por línea, sin arrays externos. Requerido por Splunk HEC, Elastic / OpenSearch, y Grafana Loki.", + "httpDestFormatSingleTitle": "Un evento por solicitud", + "httpDestFormatSingleDescription": "Envía un HTTP POST separado para cada evento individual. Úsalo sólo para los extremos que no pueden manejar lotes.", + "httpDestLogTypesTitle": "Tipos de Log", + "httpDestLogTypesDescription": "Elija qué tipos de registro son reenviados a este destino. Sólo los tipos de registro habilitados serán transmitidos.", + "httpDestAccessLogsTitle": "Registros de acceso", + "httpDestAccessLogsDescription": "Intentos de acceso a recursos, incluyendo solicitudes autenticadas y denegadas.", + "httpDestActionLogsTitle": "Registros de acción", + "httpDestActionLogsDescription": "Acciones administrativas realizadas por los usuarios dentro de la organización.", + "httpDestConnectionLogsTitle": "Registros de conexión", + "httpDestConnectionLogsDescription": "Eventos de conexión de sitios y túneles, incluyendo conexiones y desconexiones.", + "httpDestRequestLogsTitle": "Registros de Solicitud", + "httpDestRequestLogsDescription": "Registros de peticiones HTTP para recursos proxyficados, incluyendo método, ruta y código de respuesta.", + "httpDestSaveChanges": "Guardar Cambios", + "httpDestCreateDestination": "Crear destino", + "httpDestUpdatedSuccess": "Destino actualizado correctamente", + "httpDestCreatedSuccess": "Destino creado correctamente", + "httpDestUpdateFailed": "Error al actualizar destino", + "httpDestCreateFailed": "Error al crear el destino" } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index ec3dbffb8..792775fdd 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -148,6 +148,11 @@ "createLink": "Créer un lien", "resourcesNotFound": "Aucune ressource trouvée", "resourceSearch": "Rechercher des ressources", + "machineSearch": "Rechercher des machines", + "machinesSearch": "Rechercher des clients de la machine...", + "machineNotFound": "Aucune machine trouvée", + "userDeviceSearch": "Rechercher des périphériques utilisateur", + "userDevicesSearch": "Rechercher des appareils utilisateurs...", "openMenu": "Ouvrir le menu", "resource": "Ressource", "title": "Titre de la page", @@ -323,6 +328,54 @@ "apiKeysDelete": "Supprimer la clé d'API", "apiKeysManage": "Gérer les clés d'API", "apiKeysDescription": "Les clés d'API sont utilisées pour s'authentifier avec l'API d'intégration", + "provisioningKeysTitle": "Clé de provisioning", + "provisioningKeysManage": "Gérer les clés de provisioning", + "provisioningKeysDescription": "Les clés de provisioning sont utilisées pour authentifier la fourniture automatique de sites pour votre organisation.", + "provisioningManage": "Mise en place", + "provisioningDescription": "Gérer les clés de provisioning et examiner les sites en attente d'approbation.", + "pendingSites": "Sites en attente", + "siteApproveSuccess": "Site approuvé avec succès", + "siteApproveError": "Erreur lors de l'approbation du site", + "provisioningKeys": "Clés de provisionnement", + "searchProvisioningKeys": "Recherche des clés de provision...", + "provisioningKeysAdd": "Générer une clé de provisioning", + "provisioningKeysErrorDelete": "Erreur lors de la suppression de la clé de provisioning", + "provisioningKeysErrorDeleteMessage": "Erreur lors de la suppression de la clé de provisioning", + "provisioningKeysQuestionRemove": "Êtes-vous sûr de vouloir supprimer cette clé de provisioning de l'organisation ?", + "provisioningKeysMessageRemove": "Une fois supprimée, la clé ne peut plus être utilisée pour le provisionnement du site.", + "provisioningKeysDeleteConfirm": "Confirmer la suppression de la clé de provisioning", + "provisioningKeysDelete": "Supprimer la clé de provisioning", + "provisioningKeysCreate": "Générer une clé de provisioning", + "provisioningKeysCreateDescription": "Générer une nouvelle clé de provisioning pour l'organisation", + "provisioningKeysSeeAll": "Voir toutes les clés de provisioning", + "provisioningKeysSave": "Enregistrer la clé de provisioning", + "provisioningKeysSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Copiez-le dans un endroit sécurisé.", + "provisioningKeysErrorCreate": "Erreur lors de la création de la clé de provisioning", + "provisioningKeysList": "Nouvelle clé de provisioning", + "provisioningKeysMaxBatchSize": "Taille maximale du lot", + "provisioningKeysUnlimitedBatchSize": "Taille de lot illimitée (sans limite)", + "provisioningKeysMaxBatchUnlimited": "Illimité", + "provisioningKeysMaxBatchSizeInvalid": "Entrez une taille de lot maximale valide (1–1 000 000).", + "provisioningKeysValidUntil": "Valable jusqu'au", + "provisioningKeysValidUntilHint": "Laisser vide pour ne pas expirer.", + "provisioningKeysValidUntilInvalid": "Entrez une date et une heure valides.", + "provisioningKeysNumUsed": "Nombre de fois utilisées", + "provisioningKeysLastUsed": "Dernière utilisation", + "provisioningKeysNoExpiry": "Pas d'expiration", + "provisioningKeysNeverUsed": "Jamais", + "provisioningKeysEdit": "Modifier la clé de provisioning", + "provisioningKeysEditDescription": "Mettre à jour la taille maximale du lot et la durée d'expiration de cette clé.", + "provisioningKeysApproveNewSites": "Approuver les nouveaux sites", + "provisioningKeysApproveNewSitesDescription": "Approuver automatiquement les sites qui s'inscrivent avec cette clé.", + "provisioningKeysUpdateError": "Erreur lors de la mise à jour de la clé de provisioning", + "provisioningKeysUpdated": "Clé de provisioning mise à jour", + "provisioningKeysUpdatedDescription": "Vos modifications ont été enregistrées.", + "provisioningKeysBannerTitle": "Clés de provisioning du site", + "provisioningKeysBannerDescription": "Générez une clé de provisioning et utilisez-la avec le connecteur Newt pour créer automatiquement des sites au premier démarrage — pas besoin de configurer des identifiants distincts pour chaque site.", + "provisioningKeysBannerButtonText": "En savoir plus", + "pendingSitesBannerTitle": "Sites en attente", + "pendingSitesBannerDescription": "Les sites qui se connectent à l'aide d'une clé de provisioning apparaissent ici pour être revus. Approuver chaque site avant qu'il ne devienne actif et qu'il accède à vos ressources.", + "pendingSitesBannerButtonText": "En savoir plus", "apiKeysSettings": "Paramètres de {apiKeyName}", "userTitle": "Gérer tous les utilisateurs", "userDescription": "Voir et gérer tous les utilisateurs du système", @@ -509,9 +562,12 @@ "userSaved": "Utilisateur enregistré", "userSavedDescription": "L'utilisateur a été mis à jour.", "autoProvisioned": "Auto-provisionné", + "autoProvisionSettings": "Paramètres de la fourniture automatique", "autoProvisionedDescription": "Permettre à cet utilisateur d'être géré automatiquement par le fournisseur d'identité", "accessControlsDescription": "Gérer ce que cet utilisateur peut accéder et faire dans l'organisation", "accessControlsSubmit": "Enregistrer les contrôles d'accès", + "singleRolePerUserPlanNotice": "Votre plan ne prend en charge qu'un seul rôle par utilisateur.", + "singleRolePerUserEditionNotice": "Cette édition ne prend en charge qu'un rôle par utilisateur.", "roles": "Rôles", "accessUsersRoles": "Gérer les utilisateurs et les rôles", "accessUsersRolesDescription": "Invitez des utilisateurs et ajoutez-les aux rôles pour gérer l'accès à l'organisation", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.", "setupTokenRequired": "Le jeton de configuration est requis.", "actionUpdateSite": "Mettre à jour un site", + "actionResetSiteBandwidth": "Réinitialiser la bande passante de l'organisation", "actionListSiteRoles": "Lister les rôles autorisés du site", "actionCreateResource": "Créer une ressource", "actionDeleteResource": "Supprimer une ressource", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Supprimer un utilisateur", "actionListUsers": "Lister les utilisateurs", "actionAddUserRole": "Ajouter un rôle utilisateur", + "actionSetUserOrgRoles": "Définir les rôles de l'utilisateur", "actionGenerateAccessToken": "Générer un jeton d'accès", "actionDeleteAccessToken": "Supprimer un jeton d'accès", "actionListAccessTokens": "Lister les jetons d'accès", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Rôles", "sidebarShareableLinks": "Liens", "sidebarApiKeys": "Clés API", + "sidebarProvisioning": "Mise en place", "sidebarSettings": "Réglages", "sidebarAllUsers": "Tous les utilisateurs", "sidebarIdentityProviders": "Fournisseurs d'identité", @@ -1889,6 +1948,40 @@ "exitNode": "Nœud de sortie", "country": "Pays", "rulesMatchCountry": "Actuellement basé sur l'IP source", + "region": "Région", + "selectRegion": "Sélectionner une région", + "searchRegions": "Rechercher des régions...", + "noRegionFound": "Aucune région trouvée.", + "rulesMatchRegion": "Sélectionnez un groupement régional de pays", + "rulesErrorInvalidRegion": "Région invalide", + "rulesErrorInvalidRegionDescription": "Veuillez sélectionner une région valide.", + "regionAfrica": "L'Afrique", + "regionNorthernAfrica": "Afrique du Nord", + "regionEasternAfrica": "Afrique de l'Est", + "regionMiddleAfrica": "Afrique Moyenne", + "regionSouthernAfrica": "Afrique australe", + "regionWesternAfrica": "Afrique de l'Ouest", + "regionAmericas": "Amériques", + "regionCaribbean": "Caraïbes", + "regionCentralAmerica": "Amérique centrale", + "regionSouthAmerica": "Amérique du Sud", + "regionNorthernAmerica": "Amérique du Nord", + "regionAsia": "L'Asie", + "regionCentralAsia": "Asie centrale", + "regionEasternAsia": "Asie de l'Est", + "regionSouthEasternAsia": "Asie du Sud-Est", + "regionSouthernAsia": "Asie du Sud", + "regionWesternAsia": "Asie de l'Ouest", + "regionEurope": "L’Europe", + "regionEasternEurope": "Europe de l'Est", + "regionNorthernEurope": "Europe du Nord", + "regionSouthernEurope": "Europe du Sud", + "regionWesternEurope": "Europe occidentale", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Australie et Nouvelle-Zélande", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "Gestion autonome", "description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires", @@ -1937,6 +2030,25 @@ "invalidValue": "Valeur non valide", "idpTypeLabel": "Type de fournisseur d'identité", "roleMappingExpressionPlaceholder": "ex: contenu(groupes) && 'admin' || 'membre'", + "roleMappingModeFixedRoles": "Rôles fixes", + "roleMappingModeMappingBuilder": "Constructeur de cartographie", + "roleMappingModeRawExpression": "Expression brute", + "roleMappingFixedRolesPlaceholderSelect": "Sélectionnez un ou plusieurs rôles", + "roleMappingFixedRolesPlaceholderFreeform": "Tapez les noms des rôles (correspondance exacte par organisation)", + "roleMappingFixedRolesDescriptionSameForAll": "Assigner le même jeu de rôles à chaque utilisateur auto-provisionné.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "Pour les politiques par défaut, les noms de rôles de type qui existent dans chaque organisation où les utilisateurs sont fournis. Les noms doivent correspondre exactement.", + "roleMappingClaimPath": "Chemin de revendication", + "roleMappingClaimPathPlaceholder": "Groupes", + "roleMappingClaimPathDescription": "Chemin dans le bloc de jeton qui contient les valeurs source (par exemple, les groupes).", + "roleMappingMatchValue": "Valeur de la correspondance", + "roleMappingAssignRoles": "Assigner des rôles", + "roleMappingAddMappingRule": "Ajouter une règle de mappage", + "roleMappingRawExpressionResultDescription": "L'expression doit être évaluée à une chaîne ou un tableau de chaînes.", + "roleMappingRawExpressionResultDescriptionSingleRole": "L'expression doit être évaluée à une chaîne (un seul nom de rôle).", + "roleMappingMatchValuePlaceholder": "Valeur de la correspondance (par exemple: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Tapez les noms des rôles (exact par org)", + "roleMappingBuilderFreeformRowHint": "Les noms de rôle doivent correspondre à un rôle dans chaque organisation cible.", + "roleMappingRemoveRule": "Supprimer", "idpGoogleConfiguration": "Configuration Google", "idpGoogleConfigurationDescription": "Configurer les identifiants Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Durée de conservation des journaux d'accès", "logRetentionActionLabel": "Retention du journal des actions", "logRetentionActionDescription": "Durée de conservation du journal des actions", + "logRetentionConnectionLabel": "Rétention du journal de connexion", + "logRetentionConnectionDescription": "Durée de conservation des logs de connexion", "logRetentionDisabled": "Désactivé", "logRetention3Days": "3 jours", "logRetention7Days": "7 jours", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Fin de l'année suivante", "actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation", "accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation", + "connectionLogs": "Journaux de connexion", + "connectionLogsDescription": "Voir les journaux de connexion pour les tunnels de cette organisation", + "sidebarLogsConnection": "Journaux de connexion", + "sidebarLogsStreaming": "Streaming en cours", + "sourceAddress": "Adresse source", + "destinationAddress": "Adresse de destination", + "duration": "Durée", "licenseRequiredToUse": "Une licence Enterprise Edition ou Pangolin Cloud est requise pour utiliser cette fonctionnalité. Réservez une démonstration ou une évaluation de POC.", "ossEnterpriseEditionRequired": "La version Enterprise Edition est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans Pangolin Cloud. Réservez une démo ou un essai POC.", "certResolver": "Résolveur de certificat", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Modifier un rôle et activer l'option 'Exiger les autorisations de l'appareil'. Les utilisateurs avec ce rôle auront besoin de l'approbation de l'administrateur pour les nouveaux appareils.", "approvalsEmptyStatePreviewDescription": "Aperçu: Lorsque cette option est activée, les demandes de périphérique en attente apparaîtront ici pour vérification", "approvalsEmptyStateButtonText": "Gérer les rôles", - "domainErrorTitle": "Nous avons des difficultés à vérifier votre domaine" + "domainErrorTitle": "Nous avons des difficultés à vérifier votre domaine", + "idpAdminAutoProvisionPoliciesTabHint": "Configurer les politiques de mappage des rôles et de l'organisation dans l'onglet Paramètres de la fourniture automatique.", + "streamingTitle": "Streaming d'événements", + "streamingDescription": "Diffusez en temps réel des événements de votre organisation vers des destinations externes.", + "streamingUnnamedDestination": "Destination sans nom", + "streamingNoUrlConfigured": "Aucune URL configurée", + "streamingAddDestination": "Ajouter une destination", + "streamingHttpWebhookTitle": "Webhook HTTP", + "streamingHttpWebhookDescription": "Envoyez des événements à n'importe quel point de terminaison HTTP avec une authentification flexible et un template.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Flux d'événements vers un compartiment de stockage d'objet compatible S3. Bientôt.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Transférer des événements directement sur votre compte Datadog. Prochainement.", + "streamingTypePickerDescription": "Choisissez un type de destination pour commencer.", + "streamingFailedToLoad": "Impossible de charger les destinations", + "streamingUnexpectedError": "Une erreur inattendue s'est produite.", + "streamingFailedToUpdate": "Impossible de mettre à jour la destination", + "streamingDeletedSuccess": "Destination supprimée avec succès", + "streamingFailedToDelete": "Impossible de supprimer la destination", + "streamingDeleteTitle": "Supprimer la destination", + "streamingDeleteButtonText": "Supprimer la destination", + "streamingDeleteDialogAreYouSure": "Êtes-vous sûr de vouloir supprimer", + "streamingDeleteDialogThisDestination": "cette destination", + "streamingDeleteDialogPermanentlyRemoved": "? Toutes les configurations seront définitivement supprimées.", + "httpDestEditTitle": "Modifier la destination", + "httpDestAddTitle": "Ajouter une destination HTTP", + "httpDestEditDescription": "Mettre à jour la configuration pour cette destination de streaming d'événements HTTP.", + "httpDestAddDescription": "Configurez un nouveau point de terminaison HTTP pour recevoir les événements de votre organisation.", + "httpDestTabSettings": "Réglages", + "httpDestTabHeaders": "En-têtes", + "httpDestTabBody": "Corps", + "httpDestTabLogs": "Journaux", + "httpDestNamePlaceholder": "Ma destination HTTP", + "httpDestUrlLabel": "URL de destination", + "httpDestUrlErrorHttpRequired": "L'URL doit utiliser http ou https", + "httpDestUrlErrorHttpsRequired": "HTTPS est requis pour les déploiements du cloud", + "httpDestUrlErrorInvalid": "Entrez une URL valide (par exemple https://example.com/webhook)", + "httpDestAuthTitle": "Authentification", + "httpDestAuthDescription": "Choisissez comment les requêtes à votre terminaison sont authentifiées.", + "httpDestAuthNoneTitle": "Aucune authentification", + "httpDestAuthNoneDescription": "Envoie des requêtes sans en-tête d'autorisation.", + "httpDestAuthBearerTitle": "Jeton de Porteur", + "httpDestAuthBearerDescription": "Ajoute un en-tête Authorization: Bearer à chaque requête.", + "httpDestAuthBearerPlaceholder": "Votre clé API ou votre jeton", + "httpDestAuthBasicTitle": "Authentification basique", + "httpDestAuthBasicDescription": "Ajoute une autorisation : en-tête de base . Fournissez des informations d'identification comme nom d'utilisateur:mot de passe.", + "httpDestAuthBasicPlaceholder": "nom d'utilisateur:mot de passe", + "httpDestAuthCustomTitle": "En-tête personnalisé", + "httpDestAuthCustomDescription": "Spécifiez un nom d'en-tête HTTP personnalisé et une valeur pour l'authentification (par exemple X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Nom de l'en-tête (par exemple X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Valeur de l'en-tête", + "httpDestCustomHeadersTitle": "En-têtes HTTP personnalisés", + "httpDestCustomHeadersDescription": "Ajouter des en-têtes personnalisés à chaque requête sortante. Utile pour les jetons statiques ou un type de contenu personnalisé. Par défaut, Content-Type: application/json est envoyé.", + "httpDestNoHeadersConfigured": "Aucun en-tête personnalisé configuré. Cliquez sur \"Ajouter un en-tête\" pour en ajouter un.", + "httpDestHeaderNamePlaceholder": "Nom de l'en-tête", + "httpDestHeaderValuePlaceholder": "Valeur", + "httpDestAddHeader": "Ajouter un en-tête", + "httpDestBodyTemplateTitle": "Modèle de corps personnalisé", + "httpDestBodyTemplateDescription": "Contrôle la structure de charge utile JSON envoyée à votre terminal. Si désactivé, un objet JSON par défaut est envoyé pour chaque événement.", + "httpDestEnableBodyTemplate": "Activer le modèle de corps personnalisé", + "httpDestBodyTemplateLabel": "Modèle de corps (JSON)", + "httpDestBodyTemplateHint": "Utilisez les variables de modèle pour référencer les champs d'événement dans votre charge utile.", + "httpDestPayloadFormatTitle": "Format de la charge utile", + "httpDestPayloadFormatDescription": "Comment les événements sont sérialisés dans chaque corps de requête.", + "httpDestFormatJsonArrayTitle": "Tableau JSON", + "httpDestFormatJsonArrayDescription": "Une requête par lot, le corps est un tableau JSON. Compatible avec la plupart des webhooks génériques et des datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Une requête par lot, body est un JSON délimité par une nouvelle ligne — un objet par ligne, pas de tableau extérieur. Requis par Splunk HEC, Elastic / OpenSearch, et Grafana Loki.", + "httpDestFormatSingleTitle": "Un événement par demande", + "httpDestFormatSingleDescription": "Envoie un POST HTTP séparé pour chaque événement individuel. Utilisé uniquement pour les terminaux qui ne peuvent pas gérer des lots.", + "httpDestLogTypesTitle": "Types de logs", + "httpDestLogTypesDescription": "Choisissez quels types de journaux sont envoyés à cette destination. Seuls les types de journaux activés seront diffusés.", + "httpDestAccessLogsTitle": "Journaux d'accès", + "httpDestAccessLogsDescription": "Tentatives d'accès aux ressources, y compris les demandes authentifiées et refusées.", + "httpDestActionLogsTitle": "Journaux des actions", + "httpDestActionLogsDescription": "Actions administratives effectuées par les utilisateurs au sein de l'organisation.", + "httpDestConnectionLogsTitle": "Journaux de connexion", + "httpDestConnectionLogsDescription": "Événements de connexion du site et du tunnel, y compris les connexions et les déconnexions.", + "httpDestRequestLogsTitle": "Journal des requêtes", + "httpDestRequestLogsDescription": "Journaux des requêtes HTTP pour les ressources proxiées, y compris la méthode, le chemin et le code de réponse.", + "httpDestSaveChanges": "Enregistrer les modifications", + "httpDestCreateDestination": "Créer une destination", + "httpDestUpdatedSuccess": "Destination mise à jour avec succès", + "httpDestCreatedSuccess": "Destination créée avec succès", + "httpDestUpdateFailed": "Impossible de mettre à jour la destination", + "httpDestCreateFailed": "Impossible de créer la destination" } diff --git a/messages/it-IT.json b/messages/it-IT.json index adab7879a..990e2be66 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -148,6 +148,11 @@ "createLink": "Crea Collegamento", "resourcesNotFound": "Nessuna risorsa trovata", "resourceSearch": "Cerca risorse", + "machineSearch": "Ricerca macchine", + "machinesSearch": "Cerca client macchina...", + "machineNotFound": "Nessuna macchina trovata", + "userDeviceSearch": "Cerca dispositivi utente", + "userDevicesSearch": "Cerca dispositivi utente...", "openMenu": "Apri menu", "resource": "Risorsa", "title": "Titolo", @@ -323,6 +328,54 @@ "apiKeysDelete": "Elimina Chiave API", "apiKeysManage": "Gestisci Chiavi API", "apiKeysDescription": "Le chiavi API sono utilizzate per autenticarsi con l'API di integrazione", + "provisioningKeysTitle": "Chiave Di Provvedimento", + "provisioningKeysManage": "Gestisci Chiavi Di Provvedimento", + "provisioningKeysDescription": "Le chiavi di provisioning vengono utilizzate per autenticare il provisioning automatico del sito per la tua organizzazione.", + "provisioningManage": "Accantonamento", + "provisioningDescription": "Gestire le chiavi di provisioning e rivedere i siti in attesa di approvazione.", + "pendingSites": "Siti In Attesa", + "siteApproveSuccess": "Sito approvato con successo", + "siteApproveError": "Errore nell'approvazione del sito", + "provisioningKeys": "Chiavi Di Provvedimento", + "searchProvisioningKeys": "Cerca i tasti di provisioning ...", + "provisioningKeysAdd": "Genera Chiave Di Provvedimento", + "provisioningKeysErrorDelete": "Errore nell'eliminare la chiave di provisioning", + "provisioningKeysErrorDeleteMessage": "Errore nell'eliminare la chiave di provisioning", + "provisioningKeysQuestionRemove": "Sei sicuro di voler rimuovere questa chiave di provisioning dall'organizzazione?", + "provisioningKeysMessageRemove": "Una volta rimossa, la chiave non può più essere utilizzata per il provisioning.", + "provisioningKeysDeleteConfirm": "Conferma Elimina Chiave Provvisoria", + "provisioningKeysDelete": "Elimina chiave di provisioning", + "provisioningKeysCreate": "Genera Chiave Di Provvedimento", + "provisioningKeysCreateDescription": "Genera una nuova chiave di provisioning per l'organizzazione", + "provisioningKeysSeeAll": "Vedi tutte le chiavi di provisioning", + "provisioningKeysSave": "Salva la chiave di provisioning", + "provisioningKeysSaveDescription": "Sarai in grado di vedere solo una volta. Copiarlo in un posto sicuro.", + "provisioningKeysErrorCreate": "Errore nella creazione della chiave di provisioning", + "provisioningKeysList": "Nuova chiave di provisioning", + "provisioningKeysMaxBatchSize": "Dimensione massima lotto", + "provisioningKeysUnlimitedBatchSize": "Dimensione illimitata del lotto (nessun limite)", + "provisioningKeysMaxBatchUnlimited": "Illimitato", + "provisioningKeysMaxBatchSizeInvalid": "Inserisci un lotto massimo valido (1–1.000.000).", + "provisioningKeysValidUntil": "Valido fino al", + "provisioningKeysValidUntilHint": "Lasciare vuoto per nessuna scadenza.", + "provisioningKeysValidUntilInvalid": "Inserisci una data e ora valide.", + "provisioningKeysNumUsed": "Volte usate", + "provisioningKeysLastUsed": "Ultimo utilizzo", + "provisioningKeysNoExpiry": "Nessuna scadenza", + "provisioningKeysNeverUsed": "Mai", + "provisioningKeysEdit": "Modifica Chiave Di Provvedimento", + "provisioningKeysEditDescription": "Aggiorna la dimensione massima del lotto e il tempo di scadenza per questa chiave.", + "provisioningKeysApproveNewSites": "Approva nuovi siti", + "provisioningKeysApproveNewSitesDescription": "Approvare automaticamente i siti che si registrano con questa chiave.", + "provisioningKeysUpdateError": "Errore nell'aggiornamento della chiave di provisioning", + "provisioningKeysUpdated": "Chiave di accantonamento aggiornata", + "provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.", + "provisioningKeysBannerTitle": "Chiavi Di Provvedimento Sito", + "provisioningKeysBannerDescription": "Generare una chiave di provisioning e usarla con il connettore Newt per creare automaticamente siti al primo avvio — non è necessario impostare credenziali separate per ogni sito.", + "provisioningKeysBannerButtonText": "Scopri di più", + "pendingSitesBannerTitle": "Siti In Attesa", + "pendingSitesBannerDescription": "I siti che si connettono utilizzando una chiave di provisioning appaiono qui per la revisione. Approva ogni sito prima che diventi attivo e ottenga l'accesso alle tue risorse.", + "pendingSitesBannerButtonText": "Scopri di più", "apiKeysSettings": "Impostazioni {apiKeyName}", "userTitle": "Gestisci Tutti Gli Utenti", "userDescription": "Visualizza e gestisci tutti gli utenti del sistema", @@ -509,9 +562,12 @@ "userSaved": "Utente salvato", "userSavedDescription": "L'utente è stato aggiornato.", "autoProvisioned": "Auto Provisioned", + "autoProvisionSettings": "Impostazioni Automatiche Di Fornitura", "autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità", "accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione", "accessControlsSubmit": "Salva Controlli di Accesso", + "singleRolePerUserPlanNotice": "Il tuo piano supporta solo un ruolo per utente.", + "singleRolePerUserEditionNotice": "Questa edizione supporta solo un ruolo per utente.", "roles": "Ruoli", "accessUsersRoles": "Gestisci Utenti e Ruoli", "accessUsersRolesDescription": "Invita gli utenti e aggiungili ai ruoli per gestire l'accesso all'organizzazione", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Inserisci il token di configurazione dalla console del server.", "setupTokenRequired": "Il token di configurazione è richiesto", "actionUpdateSite": "Aggiorna Sito", + "actionResetSiteBandwidth": "Reimposta Larghezza Banda Dell'Organizzazione", "actionListSiteRoles": "Elenca Ruoli Sito Consentiti", "actionCreateResource": "Crea Risorsa", "actionDeleteResource": "Elimina Risorsa", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Rimuovi Utente", "actionListUsers": "Elenca Utenti", "actionAddUserRole": "Aggiungi Ruolo Utente", + "actionSetUserOrgRoles": "Imposta Ruoli Utente", "actionGenerateAccessToken": "Genera Token di Accesso", "actionDeleteAccessToken": "Elimina Token di Accesso", "actionListAccessTokens": "Elenca Token di Accesso", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Ruoli", "sidebarShareableLinks": "Collegamenti", "sidebarApiKeys": "Chiavi API", + "sidebarProvisioning": "Accantonamento", "sidebarSettings": "Impostazioni", "sidebarAllUsers": "Tutti Gli Utenti", "sidebarIdentityProviders": "Fornitori Di Identità", @@ -1889,6 +1948,40 @@ "exitNode": "Nodo di Uscita", "country": "Paese", "rulesMatchCountry": "Attualmente basato sull'IP di origine", + "region": "Regione", + "selectRegion": "Seleziona regione", + "searchRegions": "Cerca regioni...", + "noRegionFound": "Nessuna regione trovata.", + "rulesMatchRegion": "Seleziona un raggruppamento regionale di paesi", + "rulesErrorInvalidRegion": "Regione non valida", + "rulesErrorInvalidRegionDescription": "Seleziona una regione valida.", + "regionAfrica": "Africa", + "regionNorthernAfrica": "Africa Settentrionale", + "regionEasternAfrica": "Africa Orientale", + "regionMiddleAfrica": "Africa Centrale", + "regionSouthernAfrica": "Africa Meridionale", + "regionWesternAfrica": "Africa Occidentale", + "regionAmericas": "Americhe", + "regionCaribbean": "Caraibi", + "regionCentralAmerica": "America Centrale", + "regionSouthAmerica": "America Del Sud", + "regionNorthernAmerica": "America Del Nord", + "regionAsia": "Asia", + "regionCentralAsia": "Asia Centrale", + "regionEasternAsia": "Asia Orientale", + "regionSouthEasternAsia": "Asia Sudorientale", + "regionSouthernAsia": "Asia Meridionale", + "regionWesternAsia": "Asia Occidentale", + "regionEurope": "Europa", + "regionEasternEurope": "Europa Orientale", + "regionNorthernEurope": "Europa Settentrionale", + "regionSouthernEurope": "Europa Meridionale", + "regionWesternEurope": "Europa Occidentale", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Australia e Nuova Zelanda", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "Gestito Auto-Ospitato", "description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra", @@ -1937,6 +2030,25 @@ "invalidValue": "Valore non valido", "idpTypeLabel": "Tipo Provider Identità", "roleMappingExpressionPlaceholder": "es. contiene(gruppi, 'admin') && 'Admin' 'Membro'", + "roleMappingModeFixedRoles": "Ruoli Fissi", + "roleMappingModeMappingBuilder": "Mapping Builder", + "roleMappingModeRawExpression": "Espressione Raw", + "roleMappingFixedRolesPlaceholderSelect": "Seleziona uno o più ruoli", + "roleMappingFixedRolesPlaceholderFreeform": "Digita nomi dei ruoli (corrispondenza esatta per organizzazione)", + "roleMappingFixedRolesDescriptionSameForAll": "Assegna lo stesso ruolo impostato a ogni utente auto-provisioned.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "Per i criteri predefiniti, digita i nomi dei ruoli che esistono in ogni organizzazione in cui gli utenti sono forniti. I nomi devono corrispondere esattamente.", + "roleMappingClaimPath": "Richiedi Percorso", + "roleMappingClaimPathPlaceholder": "gruppi", + "roleMappingClaimPathDescription": "Percorso nel payload del token che contiene valori sorgente (ad esempio, gruppi).", + "roleMappingMatchValue": "Valore Della Partita", + "roleMappingAssignRoles": "Assegna Ruoli", + "roleMappingAddMappingRule": "Aggiungi Regola Mappatura", + "roleMappingRawExpressionResultDescription": "Espressione deve essere valutata in una stringa o array di stringhe.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Espressione deve valutare in una stringa (un singolo nome ruolo).", + "roleMappingMatchValuePlaceholder": "Valore della corrispondenza (per esempio: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Digita i nomi dei ruoli (esatto per org)", + "roleMappingBuilderFreeformRowHint": "I nomi dei ruoli devono corrispondere a un ruolo in ogni organizzazione di destinazione.", + "roleMappingRemoveRule": "Rimuovi", "idpGoogleConfiguration": "Configurazione Google", "idpGoogleConfigurationDescription": "Configura le credenziali di Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Per quanto tempo conservare i log di accesso", "logRetentionActionLabel": "Ritenzione Registro Azioni", "logRetentionActionDescription": "Per quanto tempo conservare i log delle azioni", + "logRetentionConnectionLabel": "Ritenzione Registro Di Connessione", + "logRetentionConnectionDescription": "Per quanto tempo conservare i log di connessione", "logRetentionDisabled": "Disabilitato", "logRetention3Days": "3 giorni", "logRetention7Days": "7 giorni", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Fine dell'anno successivo", "actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione", "accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione", + "connectionLogs": "Log Di Connessione", + "connectionLogsDescription": "Visualizza i log di connessione per i tunnel in questa organizzazione", + "sidebarLogsConnection": "Log Di Connessione", + "sidebarLogsStreaming": "Streaming", + "sourceAddress": "Indirizzo Di Origine", + "destinationAddress": "Indirizzo Di Destinazione", + "duration": "Durata", "licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza Enterprise Edition o Pangolin Cloud . Prenota una demo o una prova POC.", "ossEnterpriseEditionRequired": "L' Enterprise Edition è necessaria per utilizzare questa funzione. Questa funzione è disponibile anche in Pangolin Cloud. Prenota una demo o una prova POC.", "certResolver": "Risolutore Di Certificato", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Modifica un ruolo e abilita l'opzione 'Richiedi l'approvazione del dispositivo'. Gli utenti con questo ruolo avranno bisogno dell'approvazione dell'amministratore per i nuovi dispositivi.", "approvalsEmptyStatePreviewDescription": "Anteprima: quando abilitato, le richieste di dispositivo in attesa appariranno qui per la revisione", "approvalsEmptyStateButtonText": "Gestisci Ruoli", - "domainErrorTitle": "Stiamo avendo problemi a verificare il tuo dominio" + "domainErrorTitle": "Stiamo avendo problemi a verificare il tuo dominio", + "idpAdminAutoProvisionPoliciesTabHint": "Configura la mappatura dei ruoli e le politiche di organizzazione nella scheda Auto Provision Settings.", + "streamingTitle": "Streaming Eventi", + "streamingDescription": "Trasmetti eventi dalla tua organizzazione a destinazioni esterne in tempo reale.", + "streamingUnnamedDestination": "Destinazione senza nome", + "streamingNoUrlConfigured": "Nessun URL configurato", + "streamingAddDestination": "Aggiungi Destinazione", + "streamingHttpWebhookTitle": "Webhook HTTP", + "streamingHttpWebhookDescription": "Invia eventi a qualsiasi endpoint HTTP con autenticazione e template flessibili.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Trasmetti eventi su un contenitore di archiviazione per oggetti compatibile con S3. Presto in arrivo.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Inoltra gli eventi direttamente al tuo account Datadog. In arrivo.", + "streamingTypePickerDescription": "Scegli un tipo di destinazione per iniziare.", + "streamingFailedToLoad": "Impossibile caricare le destinazioni", + "streamingUnexpectedError": "Si è verificato un errore imprevisto.", + "streamingFailedToUpdate": "Impossibile aggiornare la destinazione", + "streamingDeletedSuccess": "Destinazione eliminata con successo", + "streamingFailedToDelete": "Impossibile eliminare la destinazione", + "streamingDeleteTitle": "Elimina Destinazione", + "streamingDeleteButtonText": "Elimina Destinazione", + "streamingDeleteDialogAreYouSure": "Sei sicuro di voler eliminare", + "streamingDeleteDialogThisDestination": "questa destinazione", + "streamingDeleteDialogPermanentlyRemoved": "? Tutta la configurazione verrà definitivamente rimossa.", + "httpDestEditTitle": "Modifica Destinazione", + "httpDestAddTitle": "Aggiungi Destinazione HTTP", + "httpDestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming di eventi HTTP.", + "httpDestAddDescription": "Configura un nuovo endpoint HTTP per ricevere gli eventi della tua organizzazione.", + "httpDestTabSettings": "Impostazioni", + "httpDestTabHeaders": "Intestazioni", + "httpDestTabBody": "Corpo", + "httpDestTabLogs": "Registri", + "httpDestNamePlaceholder": "La mia destinazione HTTP", + "httpDestUrlLabel": "Url Di Destinazione", + "httpDestUrlErrorHttpRequired": "L'URL deve usare http o https", + "httpDestUrlErrorHttpsRequired": "HTTPS è richiesto sulle distribuzioni cloud", + "httpDestUrlErrorInvalid": "Inserisci un URL valido (es. https://example.com/webhook)", + "httpDestAuthTitle": "Autenticazione", + "httpDestAuthDescription": "Scegli come vengono autenticate le richieste al tuo endpoint.", + "httpDestAuthNoneTitle": "Nessuna Autenticazione", + "httpDestAuthNoneDescription": "Invia richieste senza intestazione autorizzazione.", + "httpDestAuthBearerTitle": "Token Del Portatore", + "httpDestAuthBearerDescription": "Aggiunge un'intestazione Autorizzazione: Bearer ad ogni richiesta.", + "httpDestAuthBearerPlaceholder": "La tua chiave API o token", + "httpDestAuthBasicTitle": "Autenticazione Base", + "httpDestAuthBasicDescription": "Aggiunge un'autorizzazione: intestazione di base . Fornisce le credenziali come username:password.", + "httpDestAuthBasicPlaceholder": "username:password", + "httpDestAuthCustomTitle": "Intestazione Personalizzata", + "httpDestAuthCustomDescription": "Specifica un nome e un valore di intestazione HTTP personalizzati per l'autenticazione (ad esempio X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Nome intestazione (es. X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Valore intestazione", + "httpDestCustomHeadersTitle": "Intestazioni Http Personalizzate", + "httpDestCustomHeadersDescription": "Aggiungi intestazioni personalizzate ad ogni richiesta in uscita. Utile per token statici o un tipo di contenuto personalizzato. Come impostazione predefinita, viene inviato il tipo di contenuto/json.", + "httpDestNoHeadersConfigured": "Nessuna intestazione personalizzata configurata. Fare clic su \"Aggiungi intestazione\" per aggiungerne una.", + "httpDestHeaderNamePlaceholder": "Nome intestazione", + "httpDestHeaderValuePlaceholder": "Valore", + "httpDestAddHeader": "Aggiungi Intestazione", + "httpDestBodyTemplateTitle": "Modello Corpo Personalizzato", + "httpDestBodyTemplateDescription": "Controlla la struttura JSON payload inviata al tuo endpoint. Se disabilitata, viene inviato un oggetto JSON predefinito per ogni evento.", + "httpDestEnableBodyTemplate": "Abilita modello corpo personalizzato", + "httpDestBodyTemplateLabel": "Modello Corpo (JSON)", + "httpDestBodyTemplateHint": "Usa le variabili del modello per fare riferimento ai campi dell'evento nel tuo payload.", + "httpDestPayloadFormatTitle": "Formato Payload", + "httpDestPayloadFormatDescription": "Come gli eventi sono serializzati in ogni organismo di richiesta.", + "httpDestFormatJsonArrayTitle": "JSON Array", + "httpDestFormatJsonArrayDescription": "Una richiesta per lotto, corpo è un array JSON. Compatibile con la maggior parte dei webhooks generici e Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Una richiesta per lotto, corpo è newline-delimited JSON — un oggetto per linea, nessun array esterno. Richiesto da Splunk HEC, Elastic / OpenSearch, e Grafana Loki.", + "httpDestFormatSingleTitle": "Un Evento Per Richiesta", + "httpDestFormatSingleDescription": "Invia un HTTP POST separato per ogni singolo evento. Usa solo per gli endpoint che non possono gestire i batch.", + "httpDestLogTypesTitle": "Tipi Di Log", + "httpDestLogTypesDescription": "Scegli quali tipi di log vengono inoltrati a questa destinazione. Verranno trasmessi solo i tipi di log abilitati.", + "httpDestAccessLogsTitle": "Log Accesso", + "httpDestAccessLogsDescription": "Tentativi di accesso alle risorse, comprese le richieste autenticate e negate.", + "httpDestActionLogsTitle": "Log Azioni", + "httpDestActionLogsDescription": "Azioni amministrative eseguite dagli utenti all'interno dell'organizzazione.", + "httpDestConnectionLogsTitle": "Log Di Connessione", + "httpDestConnectionLogsDescription": "Eventi di connessione al sito e al tunnel, inclusi collegamenti e disconnessioni.", + "httpDestRequestLogsTitle": "Log Richiesta", + "httpDestRequestLogsDescription": "Registri di richiesta HTTP per le risorse proxy, inclusi metodo, percorso e codice di risposta.", + "httpDestSaveChanges": "Salva Modifiche", + "httpDestCreateDestination": "Crea Destinazione", + "httpDestUpdatedSuccess": "Destinazione aggiornata con successo", + "httpDestCreatedSuccess": "Destinazione creata con successo", + "httpDestUpdateFailed": "Impossibile aggiornare la destinazione", + "httpDestCreateFailed": "Impossibile creare la destinazione" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 59f464305..e0ae07d66 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -148,6 +148,11 @@ "createLink": "링크 생성", "resourcesNotFound": "리소스가 발견되지 않았습니다.", "resourceSearch": "리소스 검색", + "machineSearch": "기계 검색", + "machinesSearch": "기계 클라이언트 검색...", + "machineNotFound": "기계를 찾을 수 없습니다", + "userDeviceSearch": "사용자 장치 검색", + "userDevicesSearch": "사용자 장치 검색...", "openMenu": "메뉴 열기", "resource": "리소스", "title": "제목", @@ -323,6 +328,54 @@ "apiKeysDelete": "API 키 삭제", "apiKeysManage": "API 키 관리", "apiKeysDescription": "API 키는 통합 API와 인증하는 데 사용됩니다.", + "provisioningKeysTitle": "프로비저닝 키", + "provisioningKeysManage": "프로비저닝 키 관리", + "provisioningKeysDescription": "프로비저닝 키는 조직의 자동 사이트 프로비저닝 인증에 사용됩니다.", + "provisioningManage": "프로비저닝", + "provisioningDescription": "프로비저닝 키를 관리하고 승인을 기다리는 사이트를 검토합니다.", + "pendingSites": "대기중인 사이트", + "siteApproveSuccess": "사이트가 성공적으로 승인되었습니다", + "siteApproveError": "사이트 승인 오류", + "provisioningKeys": "프로비저닝 키", + "searchProvisioningKeys": "프로비저닝 키 검색...", + "provisioningKeysAdd": "프로비저닝 키 생성", + "provisioningKeysErrorDelete": "프로비저닝 키 삭제 오류", + "provisioningKeysErrorDeleteMessage": "프로비저닝 키 삭제 오류", + "provisioningKeysQuestionRemove": "이 프로비저닝 키를 조직에서 제거하시겠습니까?", + "provisioningKeysMessageRemove": "제거 후에는 이 키를 사이트 프로비저닝에 사용할 수 없습니다.", + "provisioningKeysDeleteConfirm": "프로비저닝 키 삭제 확인", + "provisioningKeysDelete": "프로비저닝 키 삭제", + "provisioningKeysCreate": "프로비저닝 키 생성", + "provisioningKeysCreateDescription": "조직을 위한 새로운 프로비저닝 키 생성", + "provisioningKeysSeeAll": "모든 프로비저닝 키 보기", + "provisioningKeysSave": "프로비저닝 키 저장", + "provisioningKeysSaveDescription": "이것은 한 번만 볼 수 있습니다. 안전한 장소에 복사해 두세요.", + "provisioningKeysErrorCreate": "프로비저닝 키 생성 오류", + "provisioningKeysList": "새 프로비저닝 키", + "provisioningKeysMaxBatchSize": "최대 배치 크기", + "provisioningKeysUnlimitedBatchSize": "무제한 배치 크기 (제한 없음)", + "provisioningKeysMaxBatchUnlimited": "무제한", + "provisioningKeysMaxBatchSizeInvalid": "유효한 최대 배치 크기를 입력하세요 (1–1,000,000).", + "provisioningKeysValidUntil": "유효 기간", + "provisioningKeysValidUntilHint": "만료 날짜를 설정하지 않을 경우 빈칸으로 남겨 두세요.", + "provisioningKeysValidUntilInvalid": "유효한 날짜와 시간을 입력하세요.", + "provisioningKeysNumUsed": "사용 횟수", + "provisioningKeysLastUsed": "마지막 사용", + "provisioningKeysNoExpiry": "만료 없음", + "provisioningKeysNeverUsed": "절대", + "provisioningKeysEdit": "프로비저닝 키 수정", + "provisioningKeysEditDescription": "이 키의 최대 배치 크기 및 만료 시간을 업데이트하세요.", + "provisioningKeysApproveNewSites": "새로운 사이트 승인", + "provisioningKeysApproveNewSitesDescription": "이 키를 등록하는 사이트를 자동으로 승인합니다.", + "provisioningKeysUpdateError": "프로비저닝 키 업데이트 오류", + "provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다", + "provisioningKeysUpdatedDescription": "변경 사항이 저장되었습니다.", + "provisioningKeysBannerTitle": "사이트 프로비저닝 키", + "provisioningKeysBannerDescription": "프로비저닝 키를 생성하여 Newt 커넥터와 함께 사용해 첫 실행 시 자동으로 사이트를 생성하세요 — 각 사이트마다 별도의 인증을 설정할 필요가 없습니다.", + "provisioningKeysBannerButtonText": "자세히 알아보기", + "pendingSitesBannerTitle": "대기중인 사이트", + "pendingSitesBannerDescription": "프로비저닝 키를 사용하여 연결하는 사이트는 검토 대기 중입니다. 사이트가 활성화되어 리소스에 액세스하기 전에 각 사이트를 승인하세요.", + "pendingSitesBannerButtonText": "자세히 알아보기", "apiKeysSettings": "{apiKeyName} 설정", "userTitle": "모든 사용자 관리", "userDescription": "시스템의 모든 사용자를 보고 관리합니다", @@ -509,9 +562,12 @@ "userSaved": "사용자 저장됨", "userSavedDescription": "사용자가 업데이트되었습니다.", "autoProvisioned": "자동 프로비저닝됨", + "autoProvisionSettings": "자동 프로비저닝 설정", "autoProvisionedDescription": "이 사용자가 ID 공급자에 의해 자동으로 관리될 수 있도록 허용합니다", "accessControlsDescription": "이 사용자가 조직에서 접근하고 수행할 수 있는 작업을 관리하세요", "accessControlsSubmit": "접근 제어 저장", + "singleRolePerUserPlanNotice": "계획에는 사용자당 한 가지 역할만 지원됩니다.", + "singleRolePerUserEditionNotice": "이 판에는 사용자당 한 가지 역할만 지원됩니다.", "roles": "역할", "accessUsersRoles": "사용자 및 역할 관리", "accessUsersRolesDescription": "사용자를 초대하고 역할에 추가하여 조직에 대한 접근을 관리하세요", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.", "setupTokenRequired": "설정 토큰이 필요합니다", "actionUpdateSite": "사이트 업데이트", + "actionResetSiteBandwidth": "조직 대역폭 재설정", "actionListSiteRoles": "허용된 사이트 역할 목록", "actionCreateResource": "리소스 생성", "actionDeleteResource": "리소스 삭제", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "사용자 제거", "actionListUsers": "사용자 목록", "actionAddUserRole": "사용자 역할 추가", + "actionSetUserOrgRoles": "사용자 역할 설정", "actionGenerateAccessToken": "액세스 토큰 생성", "actionDeleteAccessToken": "액세스 토큰 삭제", "actionListAccessTokens": "액세스 토큰 목록", @@ -1264,6 +1322,7 @@ "sidebarRoles": "역할", "sidebarShareableLinks": "링크", "sidebarApiKeys": "API 키", + "sidebarProvisioning": "프로비저닝", "sidebarSettings": "설정", "sidebarAllUsers": "모든 사용자", "sidebarIdentityProviders": "신원 공급자", @@ -1889,6 +1948,40 @@ "exitNode": "종단 노드", "country": "국가", "rulesMatchCountry": "현재 소스 IP를 기반으로 합니다", + "region": "지역", + "selectRegion": "지역 선택", + "searchRegions": "지역 검색...", + "noRegionFound": "지역을 찾을 수 없습니다.", + "rulesMatchRegion": "국가의 지역 구성을 선택합니다", + "rulesErrorInvalidRegion": "잘못된 지역", + "rulesErrorInvalidRegionDescription": "유효한 지역을 선택하세요.", + "regionAfrica": "아프리카", + "regionNorthernAfrica": "북부 아프리카", + "regionEasternAfrica": "동부 아프리카", + "regionMiddleAfrica": "중부 아프리카", + "regionSouthernAfrica": "남부 아프리카", + "regionWesternAfrica": "서부 아프리카", + "regionAmericas": "아메리카", + "regionCaribbean": "카리브", + "regionCentralAmerica": "중앙 아메리카", + "regionSouthAmerica": "남아메리카", + "regionNorthernAmerica": "북미", + "regionAsia": "아시아", + "regionCentralAsia": "중앙 아시아", + "regionEasternAsia": "동아시아", + "regionSouthEasternAsia": "동남아시아", + "regionSouthernAsia": "남아시아", + "regionWesternAsia": "서아시아", + "regionEurope": "유럽", + "regionEasternEurope": "동부 유럽", + "regionNorthernEurope": "북부 유럽", + "regionSouthernEurope": "남부 유럽", + "regionWesternEurope": "서부 유럽", + "regionOceania": "오세아니아", + "regionAustraliaAndNewZealand": "호주와 뉴질랜드", + "regionMelanesia": "멜라네시아", + "regionMicronesia": "미크로네시아", + "regionPolynesia": "폴리네시아", "managedSelfHosted": { "title": "관리 자체 호스팅", "description": "더 신뢰할 수 있고 낮은 유지보수의 자체 호스팅 팡골린 서버, 추가 기능 포함", @@ -1937,6 +2030,25 @@ "invalidValue": "잘못된 값", "idpTypeLabel": "신원 공급자 유형", "roleMappingExpressionPlaceholder": "예: contains(groups, 'admin') && 'Admin' || 'Member'", + "roleMappingModeFixedRoles": "고정 역할", + "roleMappingModeMappingBuilder": "매핑 빌더", + "roleMappingModeRawExpression": "원시 표현식", + "roleMappingFixedRolesPlaceholderSelect": "하나 이상의 역할을 선택하세요", + "roleMappingFixedRolesPlaceholderFreeform": "역할 이름 입력 (조직마다 정확히 일치)", + "roleMappingFixedRolesDescriptionSameForAll": "모든 자동 프로비전 사용자에게 동일한 역할 세트를 할당합니다.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "기본 정책의 경우 사용자가 프로비저닝된 조직의 역할 이름을 입력하세요. 이름은 정확히 일치해야 합니다.", + "roleMappingClaimPath": "클레임 경로", + "roleMappingClaimPathPlaceholder": "그룹", + "roleMappingClaimPathDescription": "토큰 페이로드에서 소스 값을 포함하는 경로 (예: 그룹).", + "roleMappingMatchValue": "매치 값", + "roleMappingAssignRoles": "역할 할당", + "roleMappingAddMappingRule": "매핑 규칙 추가", + "roleMappingRawExpressionResultDescription": "표현식은 문자열 또는 문자열 배열로 평가되어야 합니다.", + "roleMappingRawExpressionResultDescriptionSingleRole": "표현식은 문자열 (단일 역할 이름)로 평가되어야 합니다.", + "roleMappingMatchValuePlaceholder": "매치 값 (예: 관리자)", + "roleMappingAssignRolesPlaceholderFreeform": "역할 이름 입력 (조직마다 정확히)", + "roleMappingBuilderFreeformRowHint": "역할 이름은 각 대상 조직의 역할과 일치해야 합니다.", + "roleMappingRemoveRule": "제거", "idpGoogleConfiguration": "Google 구성", "idpGoogleConfigurationDescription": "Google OAuth2 자격 증명을 구성합니다.", "idpGoogleClientIdDescription": "Google OAuth2 클라이언트 ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "접근 로그를 얼마나 오래 보관할지", "logRetentionActionLabel": "작업 로그 보관", "logRetentionActionDescription": "작업 로그를 얼마나 오래 보관할지", + "logRetentionConnectionLabel": "연결 로그 보유 기간", + "logRetentionConnectionDescription": "연결 로그를 얼마나 오래 보유할지", "logRetentionDisabled": "비활성화됨", "logRetention3Days": "3 일", "logRetention7Days": "7 일", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "다음 연도 말", "actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다", "accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다", + "connectionLogs": "연결 로그", + "connectionLogsDescription": "이 조직의 터널 연결 로그 보기", + "sidebarLogsConnection": "연결 로그", + "sidebarLogsStreaming": "스트리밍", + "sourceAddress": "소스 주소", + "destinationAddress": "대상 주소", + "duration": "지속 시간", "licenseRequiredToUse": "이 기능을 사용하려면 엔터프라이즈 에디션 라이선스가 필요합니다. 이 기능은 판골린 클라우드에서도 사용할 수 있습니다. 데모 또는 POC 체험을 예약하세요.", "ossEnterpriseEditionRequired": "이 기능을 사용하려면 엔터프라이즈 에디션이(가) 필요합니다. 이 기능은 판골린 클라우드에서도 사용할 수 있습니다. 데모 또는 POC 체험을 예약하세요.", "certResolver": "인증서 해결사", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "역할을 편집하고 '장치 승인 요구' 옵션을 활성화하세요. 이 역할을 가진 사용자는 새 장치에 대해 관리자의 승인이 필요합니다.", "approvalsEmptyStatePreviewDescription": "미리 보기: 활성화된 경우, 승인 대기 중인 장치 요청이 검토용으로 여기에 표시됩니다.", "approvalsEmptyStateButtonText": "역할 관리", - "domainErrorTitle": "도메인 확인에 문제가 발생했습니다." + "domainErrorTitle": "도메인 확인에 문제가 발생했습니다.", + "idpAdminAutoProvisionPoliciesTabHint": "자동 프로비저닝 설정 탭에서 역할 매핑 및 조직 정책을 구성합니다.", + "streamingTitle": "이벤트 스트리밍", + "streamingDescription": "조직의 이벤트를 외부 목적지로 실시간 전송합니다.", + "streamingUnnamedDestination": "이름이 없는 대상지", + "streamingNoUrlConfigured": "설정된 URL이 없습니다", + "streamingAddDestination": "대상지 추가", + "streamingHttpWebhookTitle": "HTTP 웹훅", + "streamingHttpWebhookDescription": "유연한 인증 및 템플릿 작성 기능을 갖춘 HTTP 엔드포인트에 이벤트를 전송합니다.", + "streamingS3Title": "아마존 S3", + "streamingS3Description": "S3 호환 객체 스토리지 버킷에 이벤트를 스트리밍합니다. 곧 제공됩니다.", + "streamingDatadogTitle": "데이터독", + "streamingDatadogDescription": "이벤트를 직접 Datadog 계정으로 전달합니다. 곧 제공됩니다.", + "streamingTypePickerDescription": "목표 유형을 선택하여 시작합니다.", + "streamingFailedToLoad": "대상 로드에 실패했습니다", + "streamingUnexpectedError": "예기치 않은 오류가 발생했습니다.", + "streamingFailedToUpdate": "대상지를 업데이트하는 데 실패했습니다", + "streamingDeletedSuccess": "대상지가 성공적으로 삭제되었습니다", + "streamingFailedToDelete": "대상지 삭제 실패", + "streamingDeleteTitle": "대상지 삭제", + "streamingDeleteButtonText": "대상지 삭제", + "streamingDeleteDialogAreYouSure": "삭제하시겠습니까", + "streamingDeleteDialogThisDestination": "이 대상지", + "streamingDeleteDialogPermanentlyRemoved": "? 모든 구성은 영구적으로 제거됩니다.", + "httpDestEditTitle": "대상지 수정", + "httpDestAddTitle": "HTTP 대상지 추가", + "httpDestEditDescription": "이 HTTP 이벤트 스트리밍 대상지의 구성을 업데이트하세요.", + "httpDestAddDescription": "조직의 이벤트 수신을 위한 새로운 HTTP 엔드포인트를 구성하세요.", + "httpDestTabSettings": "설정", + "httpDestTabHeaders": "헤더", + "httpDestTabBody": "본문", + "httpDestTabLogs": "로그", + "httpDestNamePlaceholder": "내 HTTP 대상", + "httpDestUrlLabel": "대상 URL", + "httpDestUrlErrorHttpRequired": "URL은 http 또는 https를 사용해야 합니다", + "httpDestUrlErrorHttpsRequired": "클라우드 배포에는 HTTPS가 필요합니다", + "httpDestUrlErrorInvalid": "유효한 URL을 입력하세요 (예: https://example.com/webhook)", + "httpDestAuthTitle": "인증", + "httpDestAuthDescription": "엔드포인트에 대한 요청 인증 방법을 선택하세요.", + "httpDestAuthNoneTitle": "인증 없음", + "httpDestAuthNoneDescription": "Authorization 헤더 없이 요청을 보냅니다.", + "httpDestAuthBearerTitle": "Bearer 토큰", + "httpDestAuthBearerDescription": "모든 요청에 Authorization: Bearer 헤더를 추가합니다.", + "httpDestAuthBearerPlaceholder": "API 키 또는 토큰", + "httpDestAuthBasicTitle": "기본 인증", + "httpDestAuthBasicDescription": "Authorization: Basic 헤더를 추가합니다. 자격 증명은 username:password 형식으로 제공하세요.", + "httpDestAuthBasicPlaceholder": "사용자 이름:비밀번호", + "httpDestAuthCustomTitle": "사용자 정의 헤더", + "httpDestAuthCustomDescription": "인증을 위한 사용자 정의 HTTP 헤더 이름 및 값을 지정하세요 (예: X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "헤더 이름 (예: X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "헤더 값", + "httpDestCustomHeadersTitle": "사용자 정의 HTTP 헤더", + "httpDestCustomHeadersDescription": "모든 발신 요청에 사용자 정의 헤더를 추가합니다. 정적 토큰 또는 사용자 정의 Content-Type에 유용합니다. 기본적으로 Content-Type: application/json이 전송됩니다.", + "httpDestNoHeadersConfigured": "구성된 사용자 정의 헤더가 없습니다. \"헤더 추가\"를 클릭하여 추가하세요.", + "httpDestHeaderNamePlaceholder": "헤더 이름", + "httpDestHeaderValuePlaceholder": "값", + "httpDestAddHeader": "헤더 추가", + "httpDestBodyTemplateTitle": "사용자 정의 본문 템플릿", + "httpDestBodyTemplateDescription": "엔드포인트에 전송되는 JSON 페이로드 구조를 제어합니다. 비활성화된 경우 각 이벤트에 대해 기본 JSON 객체가 전송됩니다.", + "httpDestEnableBodyTemplate": "사용자 정의 본문 템플릿 활성화", + "httpDestBodyTemplateLabel": "본문 템플릿 (JSON)", + "httpDestBodyTemplateHint": "템플릿 변수를 사용하여 페이로드에서 이벤트 필드를 참조하세요.", + "httpDestPayloadFormatTitle": "페이로드 형식", + "httpDestPayloadFormatDescription": "각 요청 본문에 이벤트가 시리얼라이즈되는 방식입니다.", + "httpDestFormatJsonArrayTitle": "JSON 배열", + "httpDestFormatJsonArrayDescription": "각 배치마다 요청 하나씩, 본문은 JSON 배열입니다. 대부분의 일반 웹훅 및 Datadog과 호환됩니다.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "각 배치마다 요청 하나씩, 본문은 줄 구분 JSON — 한 라인에 하나의 객체가 있으며 외부 배열이 없습니다. Splunk HEC, Elastic / OpenSearch, Grafana Loki에 필요합니다.", + "httpDestFormatSingleTitle": "각 요청 당 하나의 이벤트", + "httpDestFormatSingleDescription": "각 개별 이벤트에 대해 별도의 HTTP POST를 전송합니다. 배치를 처리할 수 없는 엔드포인트에만 사용하세요.", + "httpDestLogTypesTitle": "로그 유형", + "httpDestLogTypesDescription": "이 대상지에 전달될 로그 유형을 선택하세요. 활성화된 로그 유형만 스트리밍 됩니다.", + "httpDestAccessLogsTitle": "접근 로그", + "httpDestAccessLogsDescription": "인증 및 거부된 요청을 포함한 리소스 접근 시도.", + "httpDestActionLogsTitle": "작업 로그", + "httpDestActionLogsDescription": "조직 내에서 사용자가 수행한 관리 작업.", + "httpDestConnectionLogsTitle": "연결 로그", + "httpDestConnectionLogsDescription": "사이트 및 터널 연결 이벤트, 연결 및 연결 끊기를 포함합니다.", + "httpDestRequestLogsTitle": "요청 로그", + "httpDestRequestLogsDescription": "프록시된 리소스에 대한 HTTP 요청 로그, 메서드, 경로 및 응답 코드를 포함합니다.", + "httpDestSaveChanges": "변경 사항 저장", + "httpDestCreateDestination": "대상지 생성", + "httpDestUpdatedSuccess": "대상지가 성공적으로 업데이트되었습니다", + "httpDestCreatedSuccess": "대상지가 성공적으로 생성되었습니다", + "httpDestUpdateFailed": "대상지를 업데이트하는 데 실패했습니다", + "httpDestCreateFailed": "대상지를 생성하는 데 실패했습니다" } diff --git a/messages/nb-NO.json b/messages/nb-NO.json index e8a9fa9a3..751f15081 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -148,6 +148,11 @@ "createLink": "Opprett lenke", "resourcesNotFound": "Ingen ressurser funnet", "resourceSearch": "Søk i ressurser", + "machineSearch": "Søk etter maskiner", + "machinesSearch": "Søk etter maskinklienter...", + "machineNotFound": "Ingen maskiner funnet", + "userDeviceSearch": "Søk etter brukerenheter", + "userDevicesSearch": "Søk etter brukerenheter...", "openMenu": "Åpne meny", "resource": "Ressurs", "title": "Tittel", @@ -323,6 +328,54 @@ "apiKeysDelete": "Slett API-nøkkel", "apiKeysManage": "Administrer API-nøkler", "apiKeysDescription": "API-nøkler brukes for å autentisere med integrasjons-API", + "provisioningKeysTitle": "Foreløpig nøkkel", + "provisioningKeysManage": "Behandle bestemmende nøkler", + "provisioningKeysDescription": "Bestemmelsesnøkler brukes til å godkjenne automatisert nettstedsløsning for din organisasjon.", + "provisioningManage": "Levering", + "provisioningDescription": "Administrer foreløpig nøkler og gjennomgå ventende nettsteder som venter på godkjenning.", + "pendingSites": "Ventende nettsteder", + "siteApproveSuccess": "Vellykket godkjenning av nettsted", + "siteApproveError": "Feil ved godkjenning av side", + "provisioningKeys": "Foreløpig nøkler", + "searchProvisioningKeys": "Søk varer i lagrings nøkler...", + "provisioningKeysAdd": "Generer fremvisende nøkkel", + "provisioningKeysErrorDelete": "Feil under sletting av foreløpig nøkkel", + "provisioningKeysErrorDeleteMessage": "Feil under sletting av foreløpig nøkkel", + "provisioningKeysQuestionRemove": "Er du sikker på at du vil fjerne denne midlertidig nøkkelen fra organisasjonen?", + "provisioningKeysMessageRemove": "Når nøkkelen er fjernet, kan den ikke lenger brukes til anleggsavsetning.", + "provisioningKeysDeleteConfirm": "Bekreft sletting av bestemmelsesnøkkel", + "provisioningKeysDelete": "Slett bestemmelsesnøkkel", + "provisioningKeysCreate": "Generer fremvisende nøkkel", + "provisioningKeysCreateDescription": "Generer en ny foreløpig nøkkel til organisasjonen", + "provisioningKeysSeeAll": "Se alle foreløpig nøkler", + "provisioningKeysSave": "Lagre den midlertidig nøkkelen", + "provisioningKeysSaveDescription": "Du kan bare se denne én gang. Kopier det til et sikkert sted.", + "provisioningKeysErrorCreate": "Feil under oppretting av foreløpig nøkkel", + "provisioningKeysList": "Ny provisorisk nøkkel", + "provisioningKeysMaxBatchSize": "Maks størrelse på bunt", + "provisioningKeysUnlimitedBatchSize": "Ubegrenset mengde bunt (ingen begrensning)", + "provisioningKeysMaxBatchUnlimited": "Ubegrenset", + "provisioningKeysMaxBatchSizeInvalid": "Angi en gyldig sjakkstørrelse (1–1 000.000).", + "provisioningKeysValidUntil": "Gyldig til", + "provisioningKeysValidUntilHint": "La stå tomt for ingen utløp.", + "provisioningKeysValidUntilInvalid": "Angi en gyldig dato og klokkeslett.", + "provisioningKeysNumUsed": "Antall ganger brukt", + "provisioningKeysLastUsed": "Sist brukt", + "provisioningKeysNoExpiry": "Ingen utløpsdato", + "provisioningKeysNeverUsed": "Aldri", + "provisioningKeysEdit": "Rediger bestemmelsesnøkkel", + "provisioningKeysEditDescription": "Oppdater maksimal størrelse for bunt og utløpstid for denne nøkkelen.", + "provisioningKeysApproveNewSites": "Godkjenn nye nettsteder", + "provisioningKeysApproveNewSitesDescription": "Godkjenn automatisk nettsteder som registrerer deg med denne nøkkelen.", + "provisioningKeysUpdateError": "Feil under oppdatering av foreløpig nøkkel", + "provisioningKeysUpdated": "Foreslå nøkkel oppdatert", + "provisioningKeysUpdatedDescription": "Dine endringer er lagret.", + "provisioningKeysBannerTitle": "Sidens bestemmende nøkler", + "provisioningKeysBannerDescription": "Generer en foreløpig nøkkel og bruk den med Nyhetskontakten for å automatisk opprette sider ved første oppstart — trenger ikke å sette opp separat innloggingsinformasjon for hver side.", + "provisioningKeysBannerButtonText": "Lær mer", + "pendingSitesBannerTitle": "Ventende nettsteder", + "pendingSitesBannerDescription": "Nettsteder som kobler deg til ved hjelp av en bestemmelsestekst, vises her for gjennomgang. Godkjenn hvert nettsted før det blir aktivt og får tilgang til ressursene dine.", + "pendingSitesBannerButtonText": "Lær mer", "apiKeysSettings": "{apiKeyName} Innstillinger", "userTitle": "Administrer alle brukere", "userDescription": "Vis og administrer alle brukere i systemet", @@ -509,9 +562,12 @@ "userSaved": "Bruker lagret", "userSavedDescription": "Brukeren har blitt oppdatert.", "autoProvisioned": "Auto avlyst", + "autoProvisionSettings": "Auto leveringsinnstillinger", "autoProvisionedDescription": "Tillat denne brukeren å bli automatisk administrert av en identitetsleverandør", "accessControlsDescription": "Administrer hva denne brukeren kan få tilgang til og gjøre i organisasjonen", "accessControlsSubmit": "Lagre tilgangskontroller", + "singleRolePerUserPlanNotice": "Din plan støtter bare én rolle per bruker.", + "singleRolePerUserEditionNotice": "Denne utgaven støtter bare én rolle per bruker.", "roles": "Roller", "accessUsersRoles": "Administrer brukere og roller", "accessUsersRolesDescription": "Inviter brukere og legg dem til roller for å administrere tilgang til organisasjonen", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.", "setupTokenRequired": "Oppsetttoken er nødvendig", "actionUpdateSite": "Oppdater område", + "actionResetSiteBandwidth": "Tilbakestill organisasjons-båndbredde", "actionListSiteRoles": "List opp tillatte områderoller", "actionCreateResource": "Opprett ressurs", "actionDeleteResource": "Slett ressurs", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Fjern bruker", "actionListUsers": "List opp brukere", "actionAddUserRole": "Legg til brukerrolle", + "actionSetUserOrgRoles": "Angi brukerroller", "actionGenerateAccessToken": "Generer tilgangstoken", "actionDeleteAccessToken": "Slett tilgangstoken", "actionListAccessTokens": "List opp tilgangstokener", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Roller", "sidebarShareableLinks": "Lenker", "sidebarApiKeys": "API-nøkler", + "sidebarProvisioning": "Levering", "sidebarSettings": "Innstillinger", "sidebarAllUsers": "Alle brukere", "sidebarIdentityProviders": "Identitetsleverandører", @@ -1889,6 +1948,40 @@ "exitNode": "Utgangsnode", "country": "Land", "rulesMatchCountry": "For tiden basert på kilde IP", + "region": "Fylke", + "selectRegion": "Velg region", + "searchRegions": "Søk etter områder...", + "noRegionFound": "Ingen region funnet.", + "rulesMatchRegion": "Velg en regional gruppering av land", + "rulesErrorInvalidRegion": "Ugyldig område", + "rulesErrorInvalidRegionDescription": "Vennligst velg et gyldig område.", + "regionAfrica": "Afrika", + "regionNorthernAfrica": "[country name] Nord-Afrika", + "regionEasternAfrica": "Øst-Afrika", + "regionMiddleAfrica": "Middle Africa", + "regionSouthernAfrica": "Sør-Afrika", + "regionWesternAfrica": "[country name] Vest-Afrika", + "regionAmericas": "Amerika", + "regionCaribbean": "Karibia", + "regionCentralAmerica": "Sentral-Amerika", + "regionSouthAmerica": "Sør-Amerika", + "regionNorthernAmerica": "Nord-Amerika", + "regionAsia": "Asia", + "regionCentralAsia": "Sentral-Asia", + "regionEasternAsia": "Øst-Asia", + "regionSouthEasternAsia": "Sørøst-Asia", + "regionSouthernAsia": "Sørlige Asia", + "regionWesternAsia": "Vest-Asia", + "regionEurope": "Europa", + "regionEasternEurope": "Øst-Europa", + "regionNorthernEurope": "Nord-Europa", + "regionSouthernEurope": "Sørlige Europa", + "regionWesternEurope": "Vest-Europa", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Australia og New Zealand", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "Administrert selv-hostet", "description": "Sikre og lavvedlikeholdsservere, selvbetjente Pangolin med ekstra klokker, og understell", @@ -1937,6 +2030,25 @@ "invalidValue": "Ugyldig verdi", "idpTypeLabel": "Identitet leverandør type", "roleMappingExpressionPlaceholder": "F.eks. inneholder(grupper, 'admin') && 'Admin' ⋅'Medlem'", + "roleMappingModeFixedRoles": "Fast roller", + "roleMappingModeMappingBuilder": "Kartlegger bygger", + "roleMappingModeRawExpression": "Rå uttrykk", + "roleMappingFixedRolesPlaceholderSelect": "Velg en eller flere roller", + "roleMappingFixedRolesPlaceholderFreeform": "Skriv inn rollenavn (eksakt treff per organisasjon)", + "roleMappingFixedRolesDescriptionSameForAll": "Tilordne den samme rollen som er satt til hver automatisk midlertidig bruker.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "For standard policyer, type rollenavn som eksisterer i hver organisasjon der brukerne tilbys. Navn må stemmer nøyaktig.", + "roleMappingClaimPath": "Krev sti", + "roleMappingClaimPathPlaceholder": "grupper", + "roleMappingClaimPathDescription": "Sti i i token nyttelast som inneholder kildeverdier (for eksempel grupper).", + "roleMappingMatchValue": "Treff verdi", + "roleMappingAssignRoles": "Tilordne roller", + "roleMappingAddMappingRule": "Legg til tilordningsregel", + "roleMappingRawExpressionResultDescription": "Uttrykk skal vurderes til en streng eller en tekststreng.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Uttrykk må evaluere til en streng (en rollenavn).", + "roleMappingMatchValuePlaceholder": "Match verdi (for eksempel: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Angi rollenavn (eksakt per org)", + "roleMappingBuilderFreeformRowHint": "Rollenavn må samsvare med en rolle i hver målorganisasjon.", + "roleMappingRemoveRule": "Fjern", "idpGoogleConfiguration": "Google Konfigurasjon", "idpGoogleConfigurationDescription": "Konfigurer Google OAuth2 legitimasjonen", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Hvor lenge du vil beholde adgangslogger", "logRetentionActionLabel": "Handlings logg nytt", "logRetentionActionDescription": "Hvor lenge handlingen skal lagres", + "logRetentionConnectionLabel": "Logg nyhet", + "logRetentionConnectionDescription": "Hvor lenge du vil beholde tilkoblingslogger", "logRetentionDisabled": "Deaktivert", "logRetention3Days": "3 dager", "logRetention7Days": "7 dager", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Slutt på neste år", "actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen", "accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen", + "connectionLogs": "Loggfiler for tilkobling", + "connectionLogsDescription": "Vis tilkoblingslogger for tunneler i denne organisasjonen", + "sidebarLogsConnection": "Loggfiler for tilkobling", + "sidebarLogsStreaming": "Strømming", + "sourceAddress": "Kilde adresse", + "destinationAddress": "Måladresse (Automatic Translation)", + "duration": "Varighet", "licenseRequiredToUse": "En Enterprise Edition lisens eller Pangolin Cloud er påkrevd for å bruke denne funksjonen. Bestill en demo eller POC prøveversjon.", "ossEnterpriseEditionRequired": "Enterprise Edition er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i Pangolin Cloud. Bestill en demo eller POC studie.", "certResolver": "Sertifikat løser", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Rediger en rolle og aktiver alternativet 'Kreve enhetsgodkjenninger'. Brukere med denne rollen vil trenge administratorgodkjenning for nye enheter.", "approvalsEmptyStatePreviewDescription": "Forhåndsvisning: Når aktivert, ventende enhets forespørsler vil vises her for vurdering", "approvalsEmptyStateButtonText": "Administrer Roller", - "domainErrorTitle": "Vi har problemer med å verifisere domenet ditt" + "domainErrorTitle": "Vi har problemer med å verifisere domenet ditt", + "idpAdminAutoProvisionPoliciesTabHint": "Konfigurer rollegartlegging og organisasjonspolicyer på Auto leveringsinnstillinger fanen.", + "streamingTitle": "Hendelse Strømming", + "streamingDescription": "Stream hendelser fra din organisasjon til eksterne destinasjoner i sanntid.", + "streamingUnnamedDestination": "Plassering uten navn", + "streamingNoUrlConfigured": "Ingen URL konfigurert", + "streamingAddDestination": "Legg til mål", + "streamingHttpWebhookTitle": "HTTP Webhook", + "streamingHttpWebhookDescription": "Send hendelser til alle HTTP-endepunkter med fleksibel autentisering og maling.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Strøm hendelser til en S3-kompatibel objektlagringskjøt. Kommer snart.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Videresend arrangementer direkte til din Datadog-konto. Kommer snart.", + "streamingTypePickerDescription": "Velg en måltype for å komme i gang.", + "streamingFailedToLoad": "Kan ikke laste inn destinasjoner", + "streamingUnexpectedError": "En uventet feil oppstod.", + "streamingFailedToUpdate": "Kunne ikke oppdatere destinasjon", + "streamingDeletedSuccess": "Målet ble slettet", + "streamingFailedToDelete": "Kunne ikke slette destinasjon", + "streamingDeleteTitle": "Slett mål", + "streamingDeleteButtonText": "Slett mål", + "streamingDeleteDialogAreYouSure": "Er du sikker på at du vil slette", + "streamingDeleteDialogThisDestination": "denne destinasjonen", + "streamingDeleteDialogPermanentlyRemoved": "? Alle konfigurasjoner vil bli slettet permanent.", + "httpDestEditTitle": "Rediger mål", + "httpDestAddTitle": "Legg til HTTP-destinasjon", + "httpDestEditDescription": "Oppdater konfigurasjonen for denne HTTP-hendelsesstrømmedestinasjonen.", + "httpDestAddDescription": "Konfigurer et nytt HTTP endepunkt for å motta organisasjonens hendelser.", + "httpDestTabSettings": "Innstillinger", + "httpDestTabHeaders": "Overskrifter", + "httpDestTabBody": "Innhold", + "httpDestTabLogs": "Logger", + "httpDestNamePlaceholder": "Min HTTP destinasjon", + "httpDestUrlLabel": "Destinasjons URL", + "httpDestUrlErrorHttpRequired": "URL-adressen må bruke httpp eller https", + "httpDestUrlErrorHttpsRequired": "HTTPS er nødvendig for distribusjon av sky", + "httpDestUrlErrorInvalid": "Skriv inn en gyldig nettadresse (f.eks. https://eksempel.com/webhook)", + "httpDestAuthTitle": "Autentisering", + "httpDestAuthDescription": "Velg hvordan ønsker til sluttpunktet ditt er autentisert.", + "httpDestAuthNoneTitle": "Ingen godkjenning", + "httpDestAuthNoneDescription": "Sender forespørsler uten autorisasjonsoverskrift.", + "httpDestAuthBearerTitle": "Bærer Symbol", + "httpDestAuthBearerDescription": "Legger til en autorisasjon: Bearer header til hver forespørsel.", + "httpDestAuthBearerPlaceholder": "Din API-nøkkel eller token", + "httpDestAuthBasicTitle": "Standard Auth", + "httpDestAuthBasicDescription": "Legger til en godkjenning: Grunnleggende overskrift. Angi legitimasjon som brukernavn:passord.", + "httpDestAuthBasicPlaceholder": "brukernavn:passord", + "httpDestAuthCustomTitle": "Egendefinert topptekst", + "httpDestAuthCustomDescription": "Angi et egendefinert HTTP headers navn og verdi for autentisering (f.eks X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Topptekst navn (f.eks X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Header verdi", + "httpDestCustomHeadersTitle": "Egendefinerte HTTP-overskrifter", + "httpDestCustomHeadersDescription": "Legg til egendefinerte overskrifter til hver utgående forespørsel. Nyttig for statisk tokens eller en egendefinert innholdstype. Som standard blir innholdstype: applikasjon/json sendt.", + "httpDestNoHeadersConfigured": "Ingen egendefinerte overskrifter konfigurert. Klikk \"Legg til topptekst\" for å legge til en.", + "httpDestHeaderNamePlaceholder": "Navn på topptekst", + "httpDestHeaderValuePlaceholder": "Verdi", + "httpDestAddHeader": "Legg til topptekst", + "httpDestBodyTemplateTitle": "Egendefinert hovedmal", + "httpDestBodyTemplateDescription": "Kontroller JSON nyttelaststrukturen sendt til ditt endepunkt. Hvis deaktivert, sendes et standard JSON-objekt for hver hendelse.", + "httpDestEnableBodyTemplate": "Aktiver egendefinert meldingsmal", + "httpDestBodyTemplateLabel": "Kroppsmal (JSON)", + "httpDestBodyTemplateHint": "Bruk designmal variabler for å referere til eventfelt i din betaling.", + "httpDestPayloadFormatTitle": "Mål format", + "httpDestPayloadFormatDescription": "Hvordan blir hendelser serialisert inn i hver forespørselsorgan.", + "httpDestFormatJsonArrayTitle": "JSON liste", + "httpDestFormatJsonArrayDescription": "Én forespørsel per batch, innholdet er en JSON-liste. Kompatibel med de mest generiske webhooks og Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Én forespørsel per sats, innholdet er nytt avgrenset JSON — et objekt per linje, ingen ytterarray. Kreves av Splunk HEC, Elastisk/OpenSearch, og Grafana Loki.", + "httpDestFormatSingleTitle": "En hendelse per forespørsel", + "httpDestFormatSingleDescription": "Sender en separat HTTP POST for hver enkelt hendelse. Bruk bare for endepunkter som ikke kan håndtere batcher.", + "httpDestLogTypesTitle": "Logg typer", + "httpDestLogTypesDescription": "Velg hvilke loggtyper som blir videresendt til dette målet. Bare aktiverte loggtyper vil bli strømmet.", + "httpDestAccessLogsTitle": "Tilgangslogger (Automatic Translation)", + "httpDestAccessLogsDescription": "Adgangsforsøk for ressurser, inkludert godkjente og nektet forespørsler.", + "httpDestActionLogsTitle": "Handlingslogger", + "httpDestActionLogsDescription": "Administrative tiltak som utføres av brukere innenfor organisasjonen.", + "httpDestConnectionLogsTitle": "Loggfiler for tilkobling", + "httpDestConnectionLogsDescription": "Utstyrs- og tunneltilkoblingshendelser, inkludert forbindelser og frakobling.", + "httpDestRequestLogsTitle": "Forespørselslogger (Automatic Translation)", + "httpDestRequestLogsDescription": "HTTP-forespørsel logger for bekreftede ressurser, inkludert metode, bane og responskode.", + "httpDestSaveChanges": "Lagre endringer", + "httpDestCreateDestination": "Opprett mål", + "httpDestUpdatedSuccess": "Målet er oppdatert", + "httpDestCreatedSuccess": "Målet er opprettet", + "httpDestUpdateFailed": "Kunne ikke oppdatere destinasjon", + "httpDestCreateFailed": "Kan ikke opprette mål" } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 32580cc45..e38aec788 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -148,6 +148,11 @@ "createLink": "Koppeling aanmaken", "resourcesNotFound": "Geen bronnen gevonden", "resourceSearch": "Zoek bronnen", + "machineSearch": "Zoek machines", + "machinesSearch": "Zoek machine-clients...", + "machineNotFound": "Geen machines gevonden", + "userDeviceSearch": "Gebruikersapparaten zoeken", + "userDevicesSearch": "Gebruikersapparaten zoeken...", "openMenu": "Menu openen", "resource": "Bron", "title": "Aanspreektitel", @@ -323,6 +328,54 @@ "apiKeysDelete": "API-sleutel verwijderen", "apiKeysManage": "API-sleutels beheren", "apiKeysDescription": "API-sleutels worden gebruikt om te verifiëren met de integratie-API", + "provisioningKeysTitle": "Vertrekkende sleutel", + "provisioningKeysManage": "Beheren van Provisioning Sleutels", + "provisioningKeysDescription": "Provisionerende sleutels worden gebruikt om geautomatiseerde sitebepaling voor uw organisatie te verifiëren.", + "provisioningManage": "Provisie", + "provisioningDescription": "Voorzieningssleutels beheren en sites beoordelen in afwachting van goedkeuring.", + "pendingSites": "Openstaande sites", + "siteApproveSuccess": "Site succesvol goedgekeurd", + "siteApproveError": "Fout bij goedkeuren website", + "provisioningKeys": "Verhelderende sleutels", + "searchProvisioningKeys": "Zoek provisioningsleutels ...", + "provisioningKeysAdd": "Genereer Provisioning Sleutel", + "provisioningKeysErrorDelete": "Fout bij verwijderen provisioning sleutel", + "provisioningKeysErrorDeleteMessage": "Fout bij verwijderen provisioning sleutel", + "provisioningKeysQuestionRemove": "Weet u zeker dat u deze proefsleutel van de organisatie wilt verwijderen?", + "provisioningKeysMessageRemove": "Eenmaal verwijderd, kan de sleutel niet meer worden gebruikt voor site-instructie.", + "provisioningKeysDeleteConfirm": "Bevestig Verwijderen Provisione-sleutel", + "provisioningKeysDelete": "Provisione-sleutel verwijderen", + "provisioningKeysCreate": "Genereer Provisioning Sleutel", + "provisioningKeysCreateDescription": "Een nieuwe provisioningsleutel voor de organisatie genereren", + "provisioningKeysSeeAll": "Bekijk alle provisioning sleutels", + "provisioningKeysSave": "Sla de provisioning sleutel op", + "provisioningKeysSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een veilige plaats.", + "provisioningKeysErrorCreate": "Fout bij aanmaken provisioning sleutel", + "provisioningKeysList": "Nieuwe provisioning sleutel", + "provisioningKeysMaxBatchSize": "Maximale batchgrootte", + "provisioningKeysUnlimitedBatchSize": "Onbeperkte batchgrootte (geen limiet)", + "provisioningKeysMaxBatchUnlimited": "Onbeperkt", + "provisioningKeysMaxBatchSizeInvalid": "Voer een geldige maximale batchgrootte in (1–1.000,000).", + "provisioningKeysValidUntil": "Geldig tot", + "provisioningKeysValidUntilHint": "Laat leeg voor geen vervaldatum.", + "provisioningKeysValidUntilInvalid": "Voer een geldige datum en tijd in.", + "provisioningKeysNumUsed": "Aantal keer gebruikt", + "provisioningKeysLastUsed": "Laatst gebruikt", + "provisioningKeysNoExpiry": "Geen vervaldatum", + "provisioningKeysNeverUsed": "Nooit", + "provisioningKeysEdit": "Wijzig Provisioning Sleutel", + "provisioningKeysEditDescription": "Werk de maximale batchgrootte en verlooptijd voor deze sleutel bij.", + "provisioningKeysApproveNewSites": "Goedkeuren van nieuwe sites", + "provisioningKeysApproveNewSitesDescription": "Automatisch sites goedkeuren die zich registreren met deze sleutel.", + "provisioningKeysUpdateError": "Fout tijdens bijwerken provisioning sleutel", + "provisioningKeysUpdated": "Provisie sleutel bijgewerkt", + "provisioningKeysUpdatedDescription": "Uw wijzigingen zijn opgeslagen.", + "provisioningKeysBannerTitle": "Bewerkingssleutels voor websites", + "provisioningKeysBannerDescription": "Genereer een provisioning-sleutel en gebruik deze met de Newt-connector om automatisch sites aan te maken bij het opstarten van de eerste opstart- het is niet nodig om afzonderlijke inloggegevens in te stellen voor elke site.", + "provisioningKeysBannerButtonText": "Meer informatie", + "pendingSitesBannerTitle": "Openstaande sites", + "pendingSitesBannerDescription": "Sites die met elkaar verbinden met behulp van een provisioning-sleutel verschijnen hier voor beoordeling. Accepteer elke site voordat deze actief wordt en krijgt toegang tot uw bronnen.", + "pendingSitesBannerButtonText": "Meer informatie", "apiKeysSettings": "{apiKeyName} instellingen", "userTitle": "Alle gebruikers beheren", "userDescription": "Bekijk en beheer alle gebruikers in het systeem", @@ -509,9 +562,12 @@ "userSaved": "Gebruiker opgeslagen", "userSavedDescription": "De gebruiker is bijgewerkt.", "autoProvisioned": "Automatisch bevestigen", + "autoProvisionSettings": "Auto Provisie Instellingen", "autoProvisionedDescription": "Toestaan dat deze gebruiker automatisch wordt beheerd door een identiteitsprovider", "accessControlsDescription": "Beheer wat deze gebruiker toegang heeft tot en doet in de organisatie", "accessControlsSubmit": "Bewaar Toegangsbesturing", + "singleRolePerUserPlanNotice": "Uw plan ondersteunt slechts één rol per gebruiker.", + "singleRolePerUserEditionNotice": "Deze editie ondersteunt slechts één rol per gebruiker.", "roles": "Rollen", "accessUsersRoles": "Beheer Gebruikers & Rollen", "accessUsersRolesDescription": "Nodig gebruikers uit en voeg ze toe aan de rollen om toegang tot de organisatie te beheren", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.", "setupTokenRequired": "Setup-token is vereist", "actionUpdateSite": "Site bijwerken", + "actionResetSiteBandwidth": "Reset organisatieschandbreedte", "actionListSiteRoles": "Toon toegestane sitenollen", "actionCreateResource": "Bron maken", "actionDeleteResource": "Document verwijderen", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Gebruiker verwijderen", "actionListUsers": "Gebruikers weergeven", "actionAddUserRole": "Gebruikersrol toevoegen", + "actionSetUserOrgRoles": "Stel gebruikersrollen in", "actionGenerateAccessToken": "Genereer Toegangstoken", "actionDeleteAccessToken": "Verwijder toegangstoken", "actionListAccessTokens": "Lijst toegangstokens", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Rollen", "sidebarShareableLinks": "Koppelingen", "sidebarApiKeys": "API sleutels", + "sidebarProvisioning": "Provisie", "sidebarSettings": "Instellingen", "sidebarAllUsers": "Alle gebruikers", "sidebarIdentityProviders": "Identiteit aanbieders", @@ -1889,6 +1948,40 @@ "exitNode": "Exit Node", "country": "Land", "rulesMatchCountry": "Momenteel gebaseerd op bron IP", + "region": "Regio", + "selectRegion": "Selecteer regio", + "searchRegions": "Zoek regio's...", + "noRegionFound": "Geen regio gevonden.", + "rulesMatchRegion": "Selecteer een regionale groepering van landen", + "rulesErrorInvalidRegion": "Ongeldige regio", + "rulesErrorInvalidRegionDescription": "Selecteer een geldige regio.", + "regionAfrica": "Afrika", + "regionNorthernAfrica": "Noord-Afrika", + "regionEasternAfrica": "Oost Afrika", + "regionMiddleAfrica": "Midden Afrika", + "regionSouthernAfrica": "Zuidelijk Afrika", + "regionWesternAfrica": "Westelijk Afrika", + "regionAmericas": "Amerika's", + "regionCaribbean": "Caraïben", + "regionCentralAmerica": "Midden-Amerika", + "regionSouthAmerica": "Zuid Amerika", + "regionNorthernAmerica": "Noord-Amerika", + "regionAsia": "Azië", + "regionCentralAsia": "Centraal-Azië", + "regionEasternAsia": "Oost-Azië", + "regionSouthEasternAsia": "Zuid-Oost-Azië", + "regionSouthernAsia": "Zuid-Azië", + "regionWesternAsia": "Westelijk Azië", + "regionEurope": "Europa", + "regionEasternEurope": "Oost-Europa", + "regionNorthernEurope": "Noord-Europa", + "regionSouthernEurope": "Zuid-Europa", + "regionWesternEurope": "West-Europa", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Australië en Nieuw-Zeeland", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "Beheerde Self-Hosted", "description": "betrouwbaardere en slecht onderhouden Pangolin server met extra klokken en klokkenluiders", @@ -1937,6 +2030,25 @@ "invalidValue": "Ongeldige waarde", "idpTypeLabel": "Identiteit provider type", "roleMappingExpressionPlaceholder": "bijvoorbeeld bevat (groepen, 'admin') && 'Admin' ½ 'Member'", + "roleMappingModeFixedRoles": "Vaste rollen", + "roleMappingModeMappingBuilder": "Toewijzing Bouwer", + "roleMappingModeRawExpression": "Ruwe expressie", + "roleMappingFixedRolesPlaceholderSelect": "Selecteer één of meer rollen", + "roleMappingFixedRolesPlaceholderFreeform": "Typ rolnamen (exacte overeenkomst per organisatie)", + "roleMappingFixedRolesDescriptionSameForAll": "Wijs dezelfde rolset toe aan elke auto-provisioned gebruiker.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "Voor standaardbeleid, typ rolnamen die bestaan in elke organisatie waar gebruikers worden opgegeven. Namen moeten exact overeenkomen.", + "roleMappingClaimPath": "Claim pad", + "roleMappingClaimPathPlaceholder": "Groepen", + "roleMappingClaimPathDescription": "Pad in de token payload die bronwaarden bevat (bijvoorbeeld groepen).", + "roleMappingMatchValue": "Kies een waarde", + "roleMappingAssignRoles": "Rollen toewijzen", + "roleMappingAddMappingRule": "Toewijzingsregel toevoegen", + "roleMappingRawExpressionResultDescription": "Expressie moet een tekenreeks of tekenreeks evalueren.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Expressie moet evalueren naar een tekenreeks (een naam met één rol).", + "roleMappingMatchValuePlaceholder": "Overeenkomende waarde (bijvoorbeeld: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Typ rolnamen (exact per org)", + "roleMappingBuilderFreeformRowHint": "Rol namen moeten overeenkomen met een rol in elke doelorganisatie.", + "roleMappingRemoveRule": "Verwijderen", "idpGoogleConfiguration": "Google Configuratie", "idpGoogleConfigurationDescription": "Configureer de Google OAuth2-referenties", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Hoe lang de toegangslogboeken behouden blijven", "logRetentionActionLabel": "Actie log bewaring", "logRetentionActionDescription": "Hoe lang de action logs behouden moeten blijven", + "logRetentionConnectionLabel": "Connectie log bewaring", + "logRetentionConnectionDescription": "Hoe lang de verbindingslogs onderhouden", "logRetentionDisabled": "Uitgeschakeld", "logRetention3Days": "3 dagen", "logRetention7Days": "7 dagen", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Einde van volgend jaar", "actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie", "accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken", + "connectionLogs": "Connectie Logs", + "connectionLogsDescription": "Toon verbindingslogs voor tunnels in deze organisatie", + "sidebarLogsConnection": "Connectie Logs", + "sidebarLogsStreaming": "Streamen", + "sourceAddress": "Bron adres", + "destinationAddress": "Adres bestemming", + "duration": "Duur", "licenseRequiredToUse": "Een Enterprise Edition licentie of Pangolin Cloud is vereist om deze functie te gebruiken. Boek een demo of POC trial.", "ossEnterpriseEditionRequired": "De Enterprise Edition is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in Pangolin Cloud. Boek een demo of POC trial.", "certResolver": "Certificaat Resolver", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Bewerk een rol en schakel de optie 'Vereist Apparaat Goedkeuringen' in. Gebruikers met deze rol hebben admin goedkeuring nodig voor nieuwe apparaten.", "approvalsEmptyStatePreviewDescription": "Voorbeeld: Indien ingeschakeld, zullen in afwachting van apparaatverzoeken hier verschijnen om te beoordelen", "approvalsEmptyStateButtonText": "Rollen beheren", - "domainErrorTitle": "We ondervinden problemen bij het controleren van uw domein" + "domainErrorTitle": "We ondervinden problemen bij het controleren van uw domein", + "idpAdminAutoProvisionPoliciesTabHint": "Configureer rolverrekening en organisatie beleid in het Auto Provision Settings tab.", + "streamingTitle": "Event streaming", + "streamingDescription": "Stream events van uw organisatie naar externe bestemmingen in realtime.", + "streamingUnnamedDestination": "Naamloze bestemming", + "streamingNoUrlConfigured": "Geen URL ingesteld", + "streamingAddDestination": "Bestemming toevoegen", + "streamingHttpWebhookTitle": "HTTP Webhook", + "streamingHttpWebhookDescription": "Stuur gebeurtenissen naar elk HTTP eindpunt met flexibele authenticatie en template.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Stream events naar een S3-compatibele object-opslagemmer. Binnenkort beschikbaar.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Stuur gebeurtenissen rechtstreeks door naar je Datadog account. Binnenkort beschikbaar.", + "streamingTypePickerDescription": "Kies een bestemmingstype om te beginnen.", + "streamingFailedToLoad": "Laden van bestemmingen mislukt", + "streamingUnexpectedError": "Er is een onverwachte fout opgetreden.", + "streamingFailedToUpdate": "Bijwerken bestemming mislukt", + "streamingDeletedSuccess": "Bestemming succesvol verwijderd", + "streamingFailedToDelete": "Verwijderen van bestemming mislukt", + "streamingDeleteTitle": "Verwijder bestemming", + "streamingDeleteButtonText": "Verwijder bestemming", + "streamingDeleteDialogAreYouSure": "Weet u zeker dat u wilt verwijderen", + "streamingDeleteDialogThisDestination": "deze bestemming", + "streamingDeleteDialogPermanentlyRemoved": "? Alle configuratie zal permanent worden verwijderd.", + "httpDestEditTitle": "Bewerk bestemming", + "httpDestAddTitle": "Voeg HTTP bestemming toe", + "httpDestEditDescription": "Werk de configuratie voor deze HTTP-event streaming bestemming bij.", + "httpDestAddDescription": "Configureer een nieuw HTTP-eindpunt om de gebeurtenissen van uw organisatie te ontvangen.", + "httpDestTabSettings": "Instellingen", + "httpDestTabHeaders": "Kopteksten", + "httpDestTabBody": "Lichaam", + "httpDestTabLogs": "Logboeken", + "httpDestNamePlaceholder": "Mijn HTTP-bestemming", + "httpDestUrlLabel": "Bestemming URL", + "httpDestUrlErrorHttpRequired": "URL moet http of https gebruiken", + "httpDestUrlErrorHttpsRequired": "HTTPS is vereist op cloud implementaties", + "httpDestUrlErrorInvalid": "Voer een geldige URL in (bijv. https://example.com/webhook)", + "httpDestAuthTitle": "Authenticatie", + "httpDestAuthDescription": "Kies hoe verzoeken voor uw eindpunt zijn geverifieerd.", + "httpDestAuthNoneTitle": "Geen authenticatie", + "httpDestAuthNoneDescription": "Stuurt verzoeken zonder toestemmingskop.", + "httpDestAuthBearerTitle": "Betere Token", + "httpDestAuthBearerDescription": "Voegt een machtiging toe: Drager header aan elke aanvraag.", + "httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token", + "httpDestAuthBasicTitle": "Basis authenticatie", + "httpDestAuthBasicDescription": "Voegt een Authorizatie toe: Basis kop. Geef inloggegevens op als gebruikersnaam:wachtwoord.", + "httpDestAuthBasicPlaceholder": "Gebruikersnaam:wachtwoord", + "httpDestAuthCustomTitle": "Aangepaste koptekst", + "httpDestAuthCustomDescription": "Specificeer een aangepaste HTTP header naam en waarde voor authenticatie (bijv. X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Header naam (bijv. X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Header waarde", + "httpDestCustomHeadersTitle": "Aangepaste HTTP Headers", + "httpDestCustomHeadersDescription": "Voeg aangepaste headers toe aan elk uitgaande verzoek. Handig voor statische tokens of een aangepast Content-Type. Standaard Content-Type: application/json wordt verzonden.", + "httpDestNoHeadersConfigured": "Geen aangepaste headers geconfigureerd. Klik op \"Header\" om er een toe te voegen.", + "httpDestHeaderNamePlaceholder": "Naam koptekst", + "httpDestHeaderValuePlaceholder": "Waarde", + "httpDestAddHeader": "Koptekst toevoegen", + "httpDestBodyTemplateTitle": "Aangepaste Body Sjabloon", + "httpDestBodyTemplateDescription": "Bestuur de JSON payload structuur verzonden naar uw eindpunt. Indien uitgeschakeld, wordt een standaard JSON object verzonden voor elke event.", + "httpDestEnableBodyTemplate": "Aangepaste lichaam sjabloon inschakelen", + "httpDestBodyTemplateLabel": "Body sjabloon (JSON)", + "httpDestBodyTemplateHint": "Gebruik sjabloonvariabelen om te verwijzen naar gebeurtenisvelden in uw payload.", + "httpDestPayloadFormatTitle": "Payload formaat", + "httpDestPayloadFormatDescription": "Hoe evenementen worden geserialiseerd in elk verzoeklichaam.", + "httpDestFormatJsonArrayTitle": "JSON matrix", + "httpDestFormatJsonArrayDescription": "Eén verzoek per batch, lichaam is een JSON-array. Compatibel met de meeste algemene webhooks en Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Eén aanvraag per batch, lichaam is nieuwe JSON gescheiden - één object per regel, geen buitenste array. Vereist door Splunk HEC, Elastic / OpenSearch, en Grafana Loki.", + "httpDestFormatSingleTitle": "Eén afspraak per verzoek", + "httpDestFormatSingleDescription": "Stuurt een aparte HTTP POST voor elk individueel event. Gebruik alleen voor eindpunten die geen batches kunnen verwerken.", + "httpDestLogTypesTitle": "Log soorten", + "httpDestLogTypesDescription": "Kies welke log types doorgestuurd worden naar deze bestemming. Alleen ingeschakelde log types worden gestreden.", + "httpDestAccessLogsTitle": "Toegang tot logboek", + "httpDestAccessLogsDescription": "Hulpbrontoegangspogingen, inclusief geauthenticeerde en weigerde aanvragen.", + "httpDestActionLogsTitle": "Actie logs", + "httpDestActionLogsDescription": "Administratieve acties uitgevoerd door gebruikers binnen de organisatie.", + "httpDestConnectionLogsTitle": "Connectie Logs", + "httpDestConnectionLogsDescription": "Verbinding met de Site en tunnel maken verbroken, inclusief verbindingen en verbindingen.", + "httpDestRequestLogsTitle": "Logboeken aanvragen", + "httpDestRequestLogsDescription": "HTTP request logs voor proxied hulpmiddelen, waaronder methode, pad en response code.", + "httpDestSaveChanges": "Wijzigingen opslaan", + "httpDestCreateDestination": "Maak bestemming aan", + "httpDestUpdatedSuccess": "Bestemming succesvol bijgewerkt", + "httpDestCreatedSuccess": "Bestemming succesvol aangemaakt", + "httpDestUpdateFailed": "Bijwerken bestemming mislukt", + "httpDestCreateFailed": "Aanmaken bestemming mislukt" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index ba0587b94..3a9607e7c 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -148,6 +148,11 @@ "createLink": "Utwórz link", "resourcesNotFound": "Nie znaleziono zasobów", "resourceSearch": "Szukaj zasobów", + "machineSearch": "Wyszukiwarki", + "machinesSearch": "Szukaj klientów maszyn...", + "machineNotFound": "Nie znaleziono maszyn", + "userDeviceSearch": "Szukaj urządzeń użytkownika", + "userDevicesSearch": "Szukaj urządzeń użytkownika...", "openMenu": "Otwórz menu", "resource": "Zasoby", "title": "Tytuł", @@ -323,6 +328,54 @@ "apiKeysDelete": "Usuń klucz API", "apiKeysManage": "Zarządzaj kluczami API", "apiKeysDescription": "Klucze API służą do uwierzytelniania z API integracji", + "provisioningKeysTitle": "Klucz Zaopatrzenia", + "provisioningKeysManage": "Zarządzaj kluczami zaopatrzenia", + "provisioningKeysDescription": "Klucze zaopatrzenia są używane do uwierzytelniania zautomatyzowanego zaopatrzenia twojej organizacji.", + "provisioningManage": "Dostarczanie", + "provisioningDescription": "Zarządzaj kluczami rezerwacji i sprawdzaj oczekujące strony oczekujące na zatwierdzenie.", + "pendingSites": "Witryny oczekujące", + "siteApproveSuccess": "Witryna została pomyślnie zatwierdzona", + "siteApproveError": "Błąd zatwierdzania witryny", + "provisioningKeys": "Klucze Zaopatrzenia", + "searchProvisioningKeys": "Szukaj kluczy zaopatrzenia...", + "provisioningKeysAdd": "Wygeneruj klucz zaopatrzenia", + "provisioningKeysErrorDelete": "Błąd podczas usuwania klucza zaopatrzenia", + "provisioningKeysErrorDeleteMessage": "Błąd podczas usuwania klucza zaopatrzenia", + "provisioningKeysQuestionRemove": "Czy na pewno chcesz usunąć ten klucz rezerwacji z organizacji?", + "provisioningKeysMessageRemove": "Po usunięciu, klucz nie może być już używany do tworzenia witryny.", + "provisioningKeysDeleteConfirm": "Potwierdź usunięcie klucza zaopatrzenia", + "provisioningKeysDelete": "Usuń klucz zaopatrzenia", + "provisioningKeysCreate": "Wygeneruj klucz zaopatrzenia", + "provisioningKeysCreateDescription": "Wygeneruj nowy klucz tworzenia rezerw dla organizacji", + "provisioningKeysSeeAll": "Zobacz wszystkie klucze rezerwacji", + "provisioningKeysSave": "Zapisz klucz zaopatrzenia", + "provisioningKeysSaveDescription": "Możesz to zobaczyć tylko raz. Skopiuj je do bezpiecznego miejsca.", + "provisioningKeysErrorCreate": "Błąd podczas tworzenia klucza zaopatrzenia", + "provisioningKeysList": "Nowy klucz rezerwacji", + "provisioningKeysMaxBatchSize": "Maksymalny rozmiar partii", + "provisioningKeysUnlimitedBatchSize": "Nieograniczony rozmiar partii (bez limitu)", + "provisioningKeysMaxBatchUnlimited": "Nieograniczona", + "provisioningKeysMaxBatchSizeInvalid": "Wprowadź poprawny maksymalny rozmiar partii (1–1 000,000).", + "provisioningKeysValidUntil": "Ważny do", + "provisioningKeysValidUntilHint": "Pozostaw puste, aby nie wygasnąć.", + "provisioningKeysValidUntilInvalid": "Wprowadź prawidłową datę i godzinę.", + "provisioningKeysNumUsed": "Używane czasy", + "provisioningKeysLastUsed": "Ostatnio używane", + "provisioningKeysNoExpiry": "Brak wygaśnięcia", + "provisioningKeysNeverUsed": "Nigdy", + "provisioningKeysEdit": "Edytuj klucz zaopatrzenia", + "provisioningKeysEditDescription": "Zaktualizuj maksymalny rozmiar partii i czas wygaśnięcia dla tego klucza.", + "provisioningKeysApproveNewSites": "Zatwierdź nowe witryny", + "provisioningKeysApproveNewSitesDescription": "Automatycznie zatwierdzaj witryny, które rejestrują się za pomocą tego klucza.", + "provisioningKeysUpdateError": "Błąd podczas aktualizacji klucza zaopatrzenia", + "provisioningKeysUpdated": "Klucz zaopatrzenia zaktualizowany", + "provisioningKeysUpdatedDescription": "Twoje zmiany zostały zapisane.", + "provisioningKeysBannerTitle": "Klucze Zaopatrzenia witryny", + "provisioningKeysBannerDescription": "Wygeneruj klucz tworzenia rezerw i użyj go z konektorem Newt do automatycznego tworzenia witryn przy pierwszym uruchomieniu — nie ma potrzeby ustawiania oddzielnych poświadczeń dla każdej witryny.", + "provisioningKeysBannerButtonText": "Dowiedz się więcej", + "pendingSitesBannerTitle": "Witryny oczekujące", + "pendingSitesBannerDescription": "Witryny, które łączą się przy użyciu klucza zaopatrzenia, pojawiają się tutaj, aby przejrzeć. Zatwierdź każdą witrynę, zanim stanie się aktywna i uzyska dostęp do twoich zasobów.", + "pendingSitesBannerButtonText": "Dowiedz się więcej", "apiKeysSettings": "Ustawienia {apiKeyName}", "userTitle": "Zarządzaj wszystkimi użytkownikami", "userDescription": "Zobacz i zarządzaj wszystkimi użytkownikami w systemie", @@ -509,9 +562,12 @@ "userSaved": "Użytkownik zapisany", "userSavedDescription": "Użytkownik został zaktualizowany.", "autoProvisioned": "Przesłane automatycznie", + "autoProvisionSettings": "Ustawienia automatycznego dostarczania", "autoProvisionedDescription": "Pozwól temu użytkownikowi na automatyczne zarządzanie przez dostawcę tożsamości", "accessControlsDescription": "Zarządzaj tym, do czego użytkownik ma dostęp i co może robić w organizacji", "accessControlsSubmit": "Zapisz kontrole dostępu", + "singleRolePerUserPlanNotice": "Twój plan obsługuje tylko jedną rolę na użytkownika.", + "singleRolePerUserEditionNotice": "Ta edycja obsługuje tylko jedną rolę na użytkownika.", "roles": "Role", "accessUsersRoles": "Zarządzaj użytkownikami i rolami", "accessUsersRolesDescription": "Zaproś użytkowników i dodaj je do ról do zarządzania dostępem do organizacji", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.", "setupTokenRequired": "Wymagany jest token konfiguracji", "actionUpdateSite": "Aktualizuj witrynę", + "actionResetSiteBandwidth": "Zresetuj przepustowość organizacji", "actionListSiteRoles": "Lista dozwolonych ról witryny", "actionCreateResource": "Utwórz zasób", "actionDeleteResource": "Usuń zasób", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Usuń użytkownika", "actionListUsers": "Lista użytkowników", "actionAddUserRole": "Dodaj rolę użytkownika", + "actionSetUserOrgRoles": "Ustaw role użytkownika", "actionGenerateAccessToken": "Wygeneruj token dostępu", "actionDeleteAccessToken": "Usuń token dostępu", "actionListAccessTokens": "Lista tokenów dostępu", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Role", "sidebarShareableLinks": "Linki", "sidebarApiKeys": "Klucze API", + "sidebarProvisioning": "Dostarczanie", "sidebarSettings": "Ustawienia", "sidebarAllUsers": "Wszyscy użytkownicy", "sidebarIdentityProviders": "Dostawcy tożsamości", @@ -1889,6 +1948,40 @@ "exitNode": "Węzeł Wyjściowy", "country": "Kraj", "rulesMatchCountry": "Obecnie bazuje na adresie IP źródła", + "region": "Region", + "selectRegion": "Wybierz region", + "searchRegions": "Szukaj regionów...", + "noRegionFound": "Nie znaleziono regionu.", + "rulesMatchRegion": "Wybierz regionalną grupę krajów", + "rulesErrorInvalidRegion": "Nieprawidłowy region", + "rulesErrorInvalidRegionDescription": "Proszę wybrać prawidłowy region.", + "regionAfrica": "Afryka", + "regionNorthernAfrica": "Afryka Północna", + "regionEasternAfrica": "Afryka Wschodnia", + "regionMiddleAfrica": "Afryka Środkowa", + "regionSouthernAfrica": "Afryka Południowa", + "regionWesternAfrica": "Afryka Zachodnia", + "regionAmericas": "Ameryka", + "regionCaribbean": "Karaiby", + "regionCentralAmerica": "Ameryka Środkowa", + "regionSouthAmerica": "Ameryka Południowej", + "regionNorthernAmerica": "Ameryka Północna", + "regionAsia": "Akwakultura", + "regionCentralAsia": "Azja Środkowa", + "regionEasternAsia": "Azja Wschodnia", + "regionSouthEasternAsia": "Azja Południowo-Wschodnia", + "regionSouthernAsia": "Azja Południowa", + "regionWesternAsia": "Azja Zachodnia", + "regionEurope": "Europa", + "regionEasternEurope": "Europa Wschodnia", + "regionNorthernEurope": "Europa Północna", + "regionSouthernEurope": "Europa Południowa", + "regionWesternEurope": "Europa Zachodnia", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Australia i Nowa Zelandia", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "Zarządzane Samodzielnie-Hostingowane", "description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami", @@ -1937,6 +2030,25 @@ "invalidValue": "Nieprawidłowa wartość", "idpTypeLabel": "Typ dostawcy tożsamości", "roleMappingExpressionPlaceholder": "np. zawiera(grupy, 'admin') && 'Admin' || 'Członek'", + "roleMappingModeFixedRoles": "Stałe role", + "roleMappingModeMappingBuilder": "Konstruktor mapowania", + "roleMappingModeRawExpression": "Surowe wyrażenie", + "roleMappingFixedRolesPlaceholderSelect": "Wybierz jedną lub więcej ról", + "roleMappingFixedRolesPlaceholderFreeform": "Wpisz nazwy ról (dopasowanie na organizację)", + "roleMappingFixedRolesDescriptionSameForAll": "Przypisz tę samą rolę do każdego automatycznie udostępnionego użytkownika.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "W przypadku domyślnych zasad nazwy ról typu które istnieją w każdej organizacji, gdzie użytkownicy są zapisywani. Nazwy muszą się dokładnie zgadzać.", + "roleMappingClaimPath": "Ścieżka przejęcia", + "roleMappingClaimPathPlaceholder": "grupy", + "roleMappingClaimPathDescription": "Ścieżka w payloadzie tokenów, która zawiera wartości źródłowe (np. grupy).", + "roleMappingMatchValue": "Wartość dopasowania", + "roleMappingAssignRoles": "Przypisz role", + "roleMappingAddMappingRule": "Dodaj regułę mapowania", + "roleMappingRawExpressionResultDescription": "Wyrażenie musi ocenić do tablicy ciągów lub ciągów.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Wyrażenie musi oceniać ciąg znaków (pojedyncza nazwa).", + "roleMappingMatchValuePlaceholder": "Wartość dopasowania (na przykład: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Wpisz nazwy ról (aktywizacja na org)", + "roleMappingBuilderFreeformRowHint": "Nazwy ról muszą pasować do roli w każdej organizacji docelowej.", + "roleMappingRemoveRule": "Usuń", "idpGoogleConfiguration": "Konfiguracja Google", "idpGoogleConfigurationDescription": "Skonfiguruj dane logowania Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Jak długo zachować dzienniki dostępu", "logRetentionActionLabel": "Zachowanie dziennika akcji", "logRetentionActionDescription": "Jak długo zachować dzienniki akcji", + "logRetentionConnectionLabel": "Zachowanie dziennika połączeń", + "logRetentionConnectionDescription": "Jak długo zachować dzienniki połączeń", "logRetentionDisabled": "Wyłączone", "logRetention3Days": "3 dni", "logRetention7Days": "7 dni", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Koniec następnego roku", "actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji", "accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji", + "connectionLogs": "Dzienniki połączeń", + "connectionLogsDescription": "Wyświetl dzienniki połączeń dla tuneli w tej organizacji", + "sidebarLogsConnection": "Dzienniki połączeń", + "sidebarLogsStreaming": "Strumieniowanie", + "sourceAddress": "Adres źródłowy", + "destinationAddress": "Adres docelowy", + "duration": "Czas trwania", "licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja Enterprise Edition lub Pangolin Cloud . Zarezerwuj wersję demonstracyjną lub wersję próbną POC.", "ossEnterpriseEditionRequired": "Enterprise Edition jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w Pangolin Cloud. Zarezerwuj demo lub okres próbny POC.", "certResolver": "Rozwiązywanie certyfikatów", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Edytuj rolę i włącz opcję \"Wymagaj zatwierdzenia urządzenia\". Użytkownicy z tą rolą będą potrzebowali zatwierdzenia administratora dla nowych urządzeń.", "approvalsEmptyStatePreviewDescription": "Podgląd: Gdy włączone, oczekujące prośby o sprawdzenie pojawią się tutaj", "approvalsEmptyStateButtonText": "Zarządzaj rolami", - "domainErrorTitle": "Mamy problem z weryfikacją Twojej domeny" + "domainErrorTitle": "Mamy problem z weryfikacją Twojej domeny", + "idpAdminAutoProvisionPoliciesTabHint": "Skonfiguruj mapowanie ról i zasady organizacji na karcie Auto Provivision Settings.", + "streamingTitle": "Strumieniowanie wydarzeń", + "streamingDescription": "Wydarzenia strumieniowe z Twojej organizacji do zewnętrznych miejsc przeznaczenia w czasie rzeczywistym.", + "streamingUnnamedDestination": "Miejsce przeznaczenia bez nazwy", + "streamingNoUrlConfigured": "Brak skonfigurowanego adresu URL", + "streamingAddDestination": "Dodaj cel", + "streamingHttpWebhookTitle": "Webhook HTTP", + "streamingHttpWebhookDescription": "Wyślij zdarzenia do dowolnego punktu końcowego HTTP z elastycznym uwierzytelnianiem i szablonem.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Zdarzenia strumieniowe do magazynu obiektów kompatybilnych z S3. Już wkrótce.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Przekaż wydarzenia bezpośrednio do Twojego konta Datadog. Już wkrótce.", + "streamingTypePickerDescription": "Wybierz typ docelowy, aby rozpocząć.", + "streamingFailedToLoad": "Nie udało się załadować miejsc docelowych", + "streamingUnexpectedError": "Wystąpił nieoczekiwany błąd.", + "streamingFailedToUpdate": "Nie udało się zaktualizować miejsca docelowego", + "streamingDeletedSuccess": "Cel usunięty pomyślnie", + "streamingFailedToDelete": "Nie udało się usunąć miejsca docelowego", + "streamingDeleteTitle": "Usuń cel", + "streamingDeleteButtonText": "Usuń cel", + "streamingDeleteDialogAreYouSure": "Czy na pewno chcesz usunąć", + "streamingDeleteDialogThisDestination": "ten cel", + "streamingDeleteDialogPermanentlyRemoved": "? Wszystkie konfiguracje zostaną trwale usunięte.", + "httpDestEditTitle": "Edytuj cel", + "httpDestAddTitle": "Dodaj cel HTTP", + "httpDestEditDescription": "Aktualizuj konfigurację dla tego celu przesyłania strumieniowego zdarzeń HTTP.", + "httpDestAddDescription": "Skonfiguruj nowy punkt końcowy HTTP, aby otrzymywać wydarzenia organizacji.", + "httpDestTabSettings": "Ustawienia", + "httpDestTabHeaders": "Nagłówki", + "httpDestTabBody": "Ciało", + "httpDestTabLogs": "Logi", + "httpDestNamePlaceholder": "Mój cel HTTP", + "httpDestUrlLabel": "Adres docelowy", + "httpDestUrlErrorHttpRequired": "Adres URL musi używać http lub https", + "httpDestUrlErrorHttpsRequired": "HTTPS jest wymagany dla wdrożenia w chmurze", + "httpDestUrlErrorInvalid": "Wprowadź poprawny adres URL (np. https://example.com/webhook)", + "httpDestAuthTitle": "Uwierzytelnianie", + "httpDestAuthDescription": "Wybierz sposób uwierzytelniania żądań do Twojego punktu końcowego.", + "httpDestAuthNoneTitle": "Brak uwierzytelniania", + "httpDestAuthNoneDescription": "Wysyła żądania bez nagłówka autoryzacji.", + "httpDestAuthBearerTitle": "Token Bearer", + "httpDestAuthBearerDescription": "Dodaje autoryzację: nagłówek Bearer do każdego żądania.", + "httpDestAuthBearerPlaceholder": "Twój klucz API lub token", + "httpDestAuthBasicTitle": "Podstawowa Autoryzacja", + "httpDestAuthBasicDescription": "Dodaje Autoryzacja: Nagłówek Basic . Podaj poświadczenia jako nazwę użytkownika: hasło.", + "httpDestAuthBasicPlaceholder": "Nazwa użytkownika:hasło", + "httpDestAuthCustomTitle": "Niestandardowy nagłówek", + "httpDestAuthCustomDescription": "Określ niestandardową nazwę nagłówka HTTP i wartość dla uwierzytelniania (np. X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Nazwa nagłówka (np. klucz X-API)", + "httpDestAuthCustomHeaderValuePlaceholder": "Wartość nagłówka", + "httpDestCustomHeadersTitle": "Niestandardowe nagłówki HTTP", + "httpDestCustomHeadersDescription": "Dodaj własne nagłówki do każdego wychodzącego żądania. Przydatne dla tokenów statycznych lub niestandardowego typu zawartości. Domyślnie Content-Type: aplikacja/json jest wysyłane.", + "httpDestNoHeadersConfigured": "Nie skonfigurowano nagłówków niestandardowych. Kliknij \"Dodaj nagłówek\", aby go dodać.", + "httpDestHeaderNamePlaceholder": "Nazwa nagłówka", + "httpDestHeaderValuePlaceholder": "Wartość", + "httpDestAddHeader": "Dodaj nagłówek", + "httpDestBodyTemplateTitle": "Własny szablon ciała", + "httpDestBodyTemplateDescription": "Kontroluj strukturę JSON wysyłaną do Twojego punktu końcowego. Jeśli wyłączone, dla każdego zdarzenia wysyłany jest domyślny obiekt JSON.", + "httpDestEnableBodyTemplate": "Włącz niestandardowy szablon ciała", + "httpDestBodyTemplateLabel": "Szablon ciała (JSON)", + "httpDestBodyTemplateHint": "Użyj zmiennych szablonu do odniesienia pól zdarzeń w twoim payloadzie.", + "httpDestPayloadFormatTitle": "Format obciążenia", + "httpDestPayloadFormatDescription": "Jak zdarzenia są serializowane w każdym organie żądania.", + "httpDestFormatJsonArrayTitle": "Tablica JSON", + "httpDestFormatJsonArrayDescription": "Jedna prośba na partię, treść jest tablicą JSON. Kompatybilna z najbardziej ogólnymi webhookami i Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Jedno żądanie na partię, ciałem jest plik JSON rozdzielony na newline-delimited — jeden obiekt na wiersz, bez tablicy zewnętrznej. Wymagane przez Splunk HEC, Elastic / OpenSesearch i Grafana Loki.", + "httpDestFormatSingleTitle": "Jedno wydarzenie na żądanie", + "httpDestFormatSingleDescription": "Wysyła oddzielny POST HTTP dla każdego zdarzenia. Użyj tylko dla punktów końcowych, które nie mogą obsługiwać partii.", + "httpDestLogTypesTitle": "Typy logów", + "httpDestLogTypesDescription": "Wybierz, które typy logów są przekazywane do tego miejsca docelowego. Tylko włączone typy logów będą strumieniowane.", + "httpDestAccessLogsTitle": "Logi dostępu", + "httpDestAccessLogsDescription": "Próby dostępu do zasobów, w tym uwierzytelnione i odrzucone żądania.", + "httpDestActionLogsTitle": "Dzienniki działań", + "httpDestActionLogsDescription": "Działania administracyjne wykonywane przez użytkowników w organizacji.", + "httpDestConnectionLogsTitle": "Dzienniki połączeń", + "httpDestConnectionLogsDescription": "Zdarzenia związane z miejscem i tunelem, w tym połączenia i rozłączenia.", + "httpDestRequestLogsTitle": "Dzienniki żądań", + "httpDestRequestLogsDescription": "Logi żądań HTTP dla zasobów proxy, w tym metody, ścieżki i kodu odpowiedzi.", + "httpDestSaveChanges": "Zapisz zmiany", + "httpDestCreateDestination": "Utwórz cel", + "httpDestUpdatedSuccess": "Cel został pomyślnie zaktualizowany", + "httpDestCreatedSuccess": "Cel został utworzony pomyślnie", + "httpDestUpdateFailed": "Nie udało się zaktualizować miejsca docelowego", + "httpDestCreateFailed": "Nie udało się utworzyć miejsca docelowego" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 3ce98fff6..2573ed6da 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -148,6 +148,11 @@ "createLink": "Criar Link", "resourcesNotFound": "Nenhum recurso encontrado", "resourceSearch": "Recursos de pesquisa", + "machineSearch": "Procurar máquinas", + "machinesSearch": "Pesquisar clientes de máquina...", + "machineNotFound": "Nenhuma máquina encontrada", + "userDeviceSearch": "Procurar dispositivos do usuário", + "userDevicesSearch": "Pesquisar dispositivos do usuário...", "openMenu": "Abrir menu", "resource": "Recurso", "title": "Título", @@ -323,6 +328,54 @@ "apiKeysDelete": "Excluir Chave API", "apiKeysManage": "Gerir Chaves API", "apiKeysDescription": "As chaves API são usadas para autenticar com a API de integração", + "provisioningKeysTitle": "Chave de provisionamento", + "provisioningKeysManage": "Gerenciar chaves de provisionamento", + "provisioningKeysDescription": "Chaves de provisionamento são usadas para autenticar o provisionamento automatizado do site para sua organização.", + "provisioningManage": "Provisionamento", + "provisioningDescription": "Gerenciar chaves de provisionamento e revisar sites pendentes aguardando aprovação.", + "pendingSites": "Sites pendentes", + "siteApproveSuccess": "Site aprovado com sucesso", + "siteApproveError": "Erro ao aprovar site", + "provisioningKeys": "Posicionando chaves", + "searchProvisioningKeys": "Pesquisar chaves de provisionamento...", + "provisioningKeysAdd": "Gerar chave de provisionamento", + "provisioningKeysErrorDelete": "Erro ao excluir chave de provisionamento", + "provisioningKeysErrorDeleteMessage": "Erro ao excluir chave de provisionamento", + "provisioningKeysQuestionRemove": "Tem certeza de que deseja remover esta chave de provisionamento da organização?", + "provisioningKeysMessageRemove": "Uma vez removida, a chave não pode mais ser usada para o provisionamento do site.", + "provisioningKeysDeleteConfirm": "Confirmar chave de exclusão", + "provisioningKeysDelete": "Apagar chave de provisionamento", + "provisioningKeysCreate": "Gerar chave de provisionamento", + "provisioningKeysCreateDescription": "Gerar uma nova chave de provisionamento para a organização", + "provisioningKeysSeeAll": "Ver todas as chaves provisionadas", + "provisioningKeysSave": "Salvar a chave de provisionamento", + "provisioningKeysSaveDescription": "Você só será capaz de ver esta vez. Copiá-lo para um lugar seguro.", + "provisioningKeysErrorCreate": "Erro ao criar chave de provisionamento", + "provisioningKeysList": "Nova chave de aprovisionamento", + "provisioningKeysMaxBatchSize": "Tamanho máximo do lote", + "provisioningKeysUnlimitedBatchSize": "Tamanho ilimitado em lote (sem limite)", + "provisioningKeysMaxBatchUnlimited": "Ilimitado", + "provisioningKeysMaxBatchSizeInvalid": "Informe um tamanho máximo válido em lote (1–1,000,000).", + "provisioningKeysValidUntil": "Valido ate", + "provisioningKeysValidUntilHint": "Deixe em branco para nenhuma expiração.", + "provisioningKeysValidUntilInvalid": "Informe uma data e hora válidas.", + "provisioningKeysNumUsed": "Use percentual", + "provisioningKeysLastUsed": "Última utilização", + "provisioningKeysNoExpiry": "Sem vencimento", + "provisioningKeysNeverUsed": "nunca", + "provisioningKeysEdit": "Editar chave de provisionamento", + "provisioningKeysEditDescription": "Atualizar o tamanho máximo do lote e tempo de expiração para esta chave.", + "provisioningKeysApproveNewSites": "Aprovar novos sites", + "provisioningKeysApproveNewSitesDescription": "Aprovar automaticamente sites que se registram com esta chave.", + "provisioningKeysUpdateError": "Erro ao atualizar chave de provisionamento", + "provisioningKeysUpdated": "Chave de provisionamento atualizada", + "provisioningKeysUpdatedDescription": "Suas alterações foram salvas.", + "provisioningKeysBannerTitle": "Chaves de provisionamento do site", + "provisioningKeysBannerDescription": "Gerar uma chave de provisionamento e usá-la com o conector de Newt para criar automaticamente sites na primeira inicialização — não é necessário configurar credenciais separadas para cada site.", + "provisioningKeysBannerButtonText": "Saiba mais", + "pendingSitesBannerTitle": "Sites pendentes", + "pendingSitesBannerDescription": "Sites que conectam usando uma chave de provisionamento aparecem aqui para revisão. Aprovar cada site antes de se tornar ativo e ganhar acesso a seus recursos.", + "pendingSitesBannerButtonText": "Saiba mais", "apiKeysSettings": "Configurações de {apiKeyName}", "userTitle": "Gerir Todos os Utilizadores", "userDescription": "Visualizar e gerir todos os utilizadores no sistema", @@ -509,9 +562,12 @@ "userSaved": "Usuário salvo", "userSavedDescription": "O utilizador foi atualizado.", "autoProvisioned": "Auto provisionado", + "autoProvisionSettings": "Configurações de provisão automática", "autoProvisionedDescription": "Permitir que este utilizador seja gerido automaticamente pelo provedor de identidade", "accessControlsDescription": "Gerir o que este utilizador pode aceder e fazer na organização", "accessControlsSubmit": "Guardar Controlos de Acesso", + "singleRolePerUserPlanNotice": "Seu plano suporta apenas uma função por usuário.", + "singleRolePerUserEditionNotice": "Esta edição suporta apenas uma função por usuário.", "roles": "Funções", "accessUsersRoles": "Gerir Utilizadores e Funções", "accessUsersRolesDescription": "Convidar usuários e adicioná-los a funções para gerenciar o acesso à organização", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Digite o token de configuração do console do servidor.", "setupTokenRequired": "Token de configuração é necessário", "actionUpdateSite": "Atualizar Site", + "actionResetSiteBandwidth": "Redefinir banda da organização", "actionListSiteRoles": "Listar Funções Permitidas do Site", "actionCreateResource": "Criar Recurso", "actionDeleteResource": "Eliminar Recurso", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Remover Utilizador", "actionListUsers": "Listar Utilizadores", "actionAddUserRole": "Adicionar Função ao Utilizador", + "actionSetUserOrgRoles": "Definir funções do usuário", "actionGenerateAccessToken": "Gerar Token de Acesso", "actionDeleteAccessToken": "Eliminar Token de Acesso", "actionListAccessTokens": "Listar Tokens de Acesso", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Papéis", "sidebarShareableLinks": "Links", "sidebarApiKeys": "Chaves API", + "sidebarProvisioning": "Provisionamento", "sidebarSettings": "Configurações", "sidebarAllUsers": "Todos os utilizadores", "sidebarIdentityProviders": "Provedores de identidade", @@ -1889,6 +1948,40 @@ "exitNode": "Nodo de Saída", "country": "País", "rulesMatchCountry": "Atualmente baseado no IP de origem", + "region": "Região", + "selectRegion": "Selecionar região", + "searchRegions": "Procurar regiões...", + "noRegionFound": "Nenhuma região encontrada.", + "rulesMatchRegion": "Selecione um grupo regional de países", + "rulesErrorInvalidRegion": "Região inválida", + "rulesErrorInvalidRegionDescription": "Por favor, selecione uma região válida.", + "regionAfrica": "África", + "regionNorthernAfrica": "África do Norte", + "regionEasternAfrica": "África Oriental", + "regionMiddleAfrica": "África Média", + "regionSouthernAfrica": "África Austral", + "regionWesternAfrica": "África Ocidental", + "regionAmericas": "Américas", + "regionCaribbean": "Caribe", + "regionCentralAmerica": "América Central", + "regionSouthAmerica": "América do Sul", + "regionNorthernAmerica": "América do Norte", + "regionAsia": "Ásia", + "regionCentralAsia": "Ásia Central", + "regionEasternAsia": "Ásia Oriental", + "regionSouthEasternAsia": "Sudeste da Ásia", + "regionSouthernAsia": "Sudeste da Ásia", + "regionWesternAsia": "Ásia Ocidental", + "regionEurope": "Europa", + "regionEasternEurope": "Europa Oriental", + "regionNorthernEurope": "Europa do Norte", + "regionSouthernEurope": "Europa do Sul", + "regionWesternEurope": "Europa Ocidental", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "Austrália e Nova Zelândia", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "Gerenciado Auto-Hospedado", "description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos", @@ -1937,6 +2030,25 @@ "invalidValue": "Valor Inválido", "idpTypeLabel": "Tipo de provedor de identidade", "roleMappingExpressionPlaceholder": "ex.: Contem (grupos, 'administrador') && 'Administrador' 「'Membro'", + "roleMappingModeFixedRoles": "Papéis fixos", + "roleMappingModeMappingBuilder": "Mapeando Construtor", + "roleMappingModeRawExpression": "Expressão Bruta", + "roleMappingFixedRolesPlaceholderSelect": "Selecione um ou mais papéis", + "roleMappingFixedRolesPlaceholderFreeform": "Digite o nome das funções (correspondência exata por organização)", + "roleMappingFixedRolesDescriptionSameForAll": "Atribuir o mesmo conjunto de funções a cada usuário auto-provisionado.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "Para políticas padrão, nomes de funções de tipo que existem em cada organização onde os usuários são fornecidos. Nomes devem coincidir exatamente.", + "roleMappingClaimPath": "Caminho da Reivindicação", + "roleMappingClaimPathPlaceholder": "grupos", + "roleMappingClaimPathDescription": "Caminho no payload token que contém valores de origem (por exemplo, grupos).", + "roleMappingMatchValue": "Valor Correspondente", + "roleMappingAssignRoles": "Atribuir Papéis", + "roleMappingAddMappingRule": "Adicionar regra de mapeamento", + "roleMappingRawExpressionResultDescription": "Expressão deve retornar à matriz string ou string.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Expressão deve ser avaliada para uma string (um nome de função única).", + "roleMappingMatchValuePlaceholder": "Valor do jogo (por exemplo: administrador)", + "roleMappingAssignRolesPlaceholderFreeform": "Digite nomes de funções ((exact por org)", + "roleMappingBuilderFreeformRowHint": "Nomes de papéis devem corresponder a um papel em cada organizaçãoalvo.", + "roleMappingRemoveRule": "Remover", "idpGoogleConfiguration": "Configuração do Google", "idpGoogleConfigurationDescription": "Configurar as credenciais do Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Por quanto tempo manter os registros de acesso", "logRetentionActionLabel": "Ação de Retenção no Log", "logRetentionActionDescription": "Por quanto tempo manter os registros de ação", + "logRetentionConnectionLabel": "Retenção de registro de conexão", + "logRetentionConnectionDescription": "Por quanto tempo manter os registros de conexão", "logRetentionDisabled": "Desabilitado", "logRetention3Days": "3 dias", "logRetention7Days": "7 dias", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Fim do ano seguinte", "actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização", "accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização", + "connectionLogs": "Logs da conexão", + "connectionLogsDescription": "Ver logs de conexão para túneis nesta organização", + "sidebarLogsConnection": "Logs da conexão", + "sidebarLogsStreaming": "Transmitindo", + "sourceAddress": "Endereço de origem", + "destinationAddress": "Endereço de destino", + "duration": "Duração", "licenseRequiredToUse": "Uma licença Enterprise Edition ou Pangolin Cloud é necessária para usar este recurso. Reserve um teste de demonstração ou POC.", "ossEnterpriseEditionRequired": "O Enterprise Edition é necessário para usar este recurso. Este recurso também está disponível no Pangolin Cloud. Reserve uma demonstração ou avaliação POC.", "certResolver": "Resolvedor de Certificado", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Editar uma função e habilitar a opção 'Exigir aprovação de dispositivos'. Usuários com essa função precisarão de aprovação de administrador para novos dispositivos.", "approvalsEmptyStatePreviewDescription": "Pré-visualização: Quando ativado, solicitações de dispositivo pendentes aparecerão aqui para revisão", "approvalsEmptyStateButtonText": "Gerir Funções", - "domainErrorTitle": "Estamos tendo problemas ao verificar seu domínio" + "domainErrorTitle": "Estamos tendo problemas ao verificar seu domínio", + "idpAdminAutoProvisionPoliciesTabHint": "Configurar funções de mapeamento e políticas de organização na aba Auto Provision Settings.", + "streamingTitle": "Streaming do Evento", + "streamingDescription": "Transmita eventos de sua organização para destinos externos em tempo real.", + "streamingUnnamedDestination": "Destino sem nome", + "streamingNoUrlConfigured": "Nenhuma URL configurada", + "streamingAddDestination": "Adicionar destino", + "streamingHttpWebhookTitle": "Webhook HTTP", + "streamingHttpWebhookDescription": "Envie os eventos para qualquer endpoint HTTP com autenticação flexível e modelo.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Transmitir eventos para um balde de armazenamento de objetos compatível com S3. Em breve.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Encaminha eventos diretamente para a sua conta no Datadog. Em breve.", + "streamingTypePickerDescription": "Escolha um tipo de destino para começar.", + "streamingFailedToLoad": "Falha ao carregar destinos", + "streamingUnexpectedError": "Ocorreu um erro inesperado.", + "streamingFailedToUpdate": "Falha ao atualizar destino", + "streamingDeletedSuccess": "Destino apagado com sucesso", + "streamingFailedToDelete": "Falha ao excluir destino", + "streamingDeleteTitle": "Excluir destino", + "streamingDeleteButtonText": "Excluir destino", + "streamingDeleteDialogAreYouSure": "Tem certeza de que deseja excluir", + "streamingDeleteDialogThisDestination": "este destino", + "streamingDeleteDialogPermanentlyRemoved": "? Todas as configurações serão permanentemente removidas.", + "httpDestEditTitle": "Editar destino", + "httpDestAddTitle": "Adicionar Destino HTTP", + "httpDestEditDescription": "Atualizar a configuração para este destino de transmissão de eventos HTTP.", + "httpDestAddDescription": "Configure um novo ponto de extremidade HTTP para receber eventos da sua organização.", + "httpDestTabSettings": "Confirgurações", + "httpDestTabHeaders": "Cabeçalhos", + "httpDestTabBody": "Conteúdo", + "httpDestTabLogs": "Registros", + "httpDestNamePlaceholder": "Meu destino HTTP", + "httpDestUrlLabel": "URL de destino", + "httpDestUrlErrorHttpRequired": "A URL deve usar http ou https", + "httpDestUrlErrorHttpsRequired": "HTTPS é necessário em implantações em nuvem", + "httpDestUrlErrorInvalid": "Informe uma URL válida (por exemplo, https://example.com/webhook)", + "httpDestAuthTitle": "Autenticação", + "httpDestAuthDescription": "Escolha como os pedidos para seu endpoint são autenticados.", + "httpDestAuthNoneTitle": "Sem Autenticação", + "httpDestAuthNoneDescription": "Envia pedidos sem um cabeçalho de autorização.", + "httpDestAuthBearerTitle": "Token do portador", + "httpDestAuthBearerDescription": "Adiciona uma autorização: Bearer header a cada requisição.", + "httpDestAuthBearerPlaceholder": "Sua chave de API ou token", + "httpDestAuthBasicTitle": "Autenticação básica", + "httpDestAuthBasicDescription": "Adiciona uma Autorização: cabeçalho básico. Forneça credenciais como nome de usuário:senha.", + "httpDestAuthBasicPlaceholder": "Usuário:password", + "httpDestAuthCustomTitle": "Cabeçalho personalizado", + "httpDestAuthCustomDescription": "Especifique um nome e valor de cabeçalho HTTP personalizado para autenticação (por exemplo, X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Nome do cabeçalho (ex: X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Valor do cabeçalho", + "httpDestCustomHeadersTitle": "Cabeçalhos HTTP personalizados", + "httpDestCustomHeadersDescription": "Adicionar cabeçalhos personalizados a todas as solicitações de saída. Útil para tokens estáticos ou um tipo de conteúdo personalizado. Por padrão, Content-Type: application/json é enviado.", + "httpDestNoHeadersConfigured": "Nenhum cabeçalho personalizado configurado. Clique em \"Adicionar Cabeçalho\" para adicionar um.", + "httpDestHeaderNamePlaceholder": "Nome do Cabeçalho", + "httpDestHeaderValuePlaceholder": "Valor", + "httpDestAddHeader": "Adicionar Cabeçalho", + "httpDestBodyTemplateTitle": "Modelo de corpo personalizado", + "httpDestBodyTemplateDescription": "Controla a estrutura de carga JSON enviada ao seu endpoint. Se desativado, um objeto JSON padrão é enviado para cada evento.", + "httpDestEnableBodyTemplate": "Ativar modelo personalizado de corpo", + "httpDestBodyTemplateLabel": "Modelo de corpo (JSON)", + "httpDestBodyTemplateHint": "Use variáveis de template para referenciar campos de evento em seu payload.", + "httpDestPayloadFormatTitle": "Formato de carga", + "httpDestPayloadFormatDescription": "Como os eventos são serializados em cada corpo do pedido.", + "httpDestFormatJsonArrayTitle": "Matriz JSON", + "httpDestFormatJsonArrayDescription": "Um pedido por lote, o corpo é um array JSON. Compatível com a maioria dos webhooks genéricos e Datadog.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Um pedido por lote, o corpo é um JSON delimitado por nova-linha — um objeto por linha, sem array exterior. Requerido pelo Splunk HEC, Elástico / OpenSearch, e Grafana Loki.", + "httpDestFormatSingleTitle": "Um Evento por Requisição", + "httpDestFormatSingleDescription": "Envia um POST HTTP separado para cada evento. Utilize apenas para endpoints que não podem manipular lotes.", + "httpDestLogTypesTitle": "Tipos de log", + "httpDestLogTypesDescription": "Escolha quais tipos de log são encaminhados para este destino. Somente serão racionalizados os tipos de logs habilitados.", + "httpDestAccessLogsTitle": "Logs de Acesso", + "httpDestAccessLogsDescription": "Tentativas de acesso a recursos, incluindo solicitações autenticadas e negadas.", + "httpDestActionLogsTitle": "Logs de Ações", + "httpDestActionLogsDescription": "Ações administrativas realizadas por usuários dentro da organização.", + "httpDestConnectionLogsTitle": "Logs da conexão", + "httpDestConnectionLogsDescription": "Eventos de conexão de site e túnel, incluindo conexões e desconexões.", + "httpDestRequestLogsTitle": "Registro de pedidos", + "httpDestRequestLogsDescription": "Logs de solicitação HTTP para recursos proxy incluindo o método, o caminho e o código de resposta.", + "httpDestSaveChanges": "Salvar as alterações", + "httpDestCreateDestination": "Criar destino", + "httpDestUpdatedSuccess": "Destino atualizado com sucesso", + "httpDestCreatedSuccess": "Destino criado com sucesso", + "httpDestUpdateFailed": "Falha ao atualizar destino", + "httpDestCreateFailed": "Falha ao criar destino" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 12043d8a2..b44ec36ed 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -148,6 +148,11 @@ "createLink": "Создать ссылку", "resourcesNotFound": "Ресурсы не найдены", "resourceSearch": "Поиск ресурсов", + "machineSearch": "Поиск машин", + "machinesSearch": "Поиск клиентов машины...", + "machineNotFound": "Машины не найдены", + "userDeviceSearch": "Поиск устройств пользователя", + "userDevicesSearch": "Поиск устройств пользователя...", "openMenu": "Открыть меню", "resource": "Ресурс", "title": "Заголовок", @@ -323,6 +328,54 @@ "apiKeysDelete": "Удаление ключа API", "apiKeysManage": "Управление ключами API", "apiKeysDescription": "Ключи API используются для аутентификации в интеграционном API", + "provisioningKeysTitle": "Ключ подготовки", + "provisioningKeysManage": "Управление ключами подготовки", + "provisioningKeysDescription": "Ключи подготовки используются для аутентификации автоматического обеспечения сайта для вашей организации.", + "provisioningManage": "Подготовка", + "provisioningDescription": "Управляйте предоставленными ключами и проверять непроверенные сайты, ожидающие утверждения.", + "pendingSites": "Ожидающие сайты", + "siteApproveSuccess": "Сайт успешно утвержден", + "siteApproveError": "Ошибка при утверждении сайта", + "provisioningKeys": "Ключи подготовки", + "searchProvisioningKeys": "Поиск подготовительных ключей...", + "provisioningKeysAdd": "Сгенерировать ключ подготовки", + "provisioningKeysErrorDelete": "Ошибка при удалении подготовительного ключа", + "provisioningKeysErrorDeleteMessage": "Ошибка при удалении подготовительного ключа", + "provisioningKeysQuestionRemove": "Вы уверены, что хотите удалить этот ключ подготовки из организации?", + "provisioningKeysMessageRemove": "После удаления ключ больше не может быть использован для размещения сайта.", + "provisioningKeysDeleteConfirm": "Подтвердите удаление ключа подготовки", + "provisioningKeysDelete": "Удалить ключ подготовки", + "provisioningKeysCreate": "Сгенерировать ключ подготовки", + "provisioningKeysCreateDescription": "Создать новый подготовительный ключ для организации", + "provisioningKeysSeeAll": "Посмотреть все подготовительные ключи", + "provisioningKeysSave": "Сохранить ключ подготовки", + "provisioningKeysSaveDescription": "Вы сможете увидеть это только один раз. Скопируйте его в безопасное место.", + "provisioningKeysErrorCreate": "Ошибка при создании ключа подготовки", + "provisioningKeysList": "Новый подготовительный ключ", + "provisioningKeysMaxBatchSize": "Макс. размер партии", + "provisioningKeysUnlimitedBatchSize": "Неограниченный размер партии (без ограничений)", + "provisioningKeysMaxBatchUnlimited": "Неограниченный", + "provisioningKeysMaxBatchSizeInvalid": "Введите максимальный размер пакета (1–1,000,000).", + "provisioningKeysValidUntil": "Действителен до", + "provisioningKeysValidUntilHint": "Оставьте пустым для отсутствия срока действия.", + "provisioningKeysValidUntilInvalid": "Введите правильную дату и время.", + "provisioningKeysNumUsed": "Использовано раз", + "provisioningKeysLastUsed": "Последнее использованное", + "provisioningKeysNoExpiry": "Без истечения срока", + "provisioningKeysNeverUsed": "Никогда", + "provisioningKeysEdit": "Редактировать ключ подготовки", + "provisioningKeysEditDescription": "Обновить максимальный размер и срок действия этого ключа.", + "provisioningKeysApproveNewSites": "Одобрить новые сайты", + "provisioningKeysApproveNewSitesDescription": "Автоматически одобрять сайты, регистрирующиеся с этим ключом.", + "provisioningKeysUpdateError": "Ошибка при обновлении ключа подготовки", + "provisioningKeysUpdated": "Ключ подготовки обновлен", + "provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.", + "provisioningKeysBannerTitle": "Ключи подготовки сайта", + "provisioningKeysBannerDescription": "Генерировать подготовительный ключ и использовать его вместе с Новым коннектором для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.", + "provisioningKeysBannerButtonText": "Узнать больше", + "pendingSitesBannerTitle": "Ожидающие сайты", + "pendingSitesBannerDescription": "Сайты, связанные с использованием ключа подготовки, появляются здесь для проверки. Одобрите каждый сайт, прежде чем он станет активным и получит доступ к вашим ресурсам.", + "pendingSitesBannerButtonText": "Узнать больше", "apiKeysSettings": "Настройки {apiKeyName}", "userTitle": "Управление всеми пользователями", "userDescription": "Просмотр и управление всеми пользователями в системе", @@ -509,9 +562,12 @@ "userSaved": "Пользователь сохранён", "userSavedDescription": "Пользователь был обновлён.", "autoProvisioned": "Автоподбор", + "autoProvisionSettings": "Настройки автоматического обеспечения", "autoProvisionedDescription": "Разрешить автоматическое управление этим пользователем", "accessControlsDescription": "Управляйте тем, к чему этот пользователь может получить доступ и что делать в организации", "accessControlsSubmit": "Сохранить контроль доступа", + "singleRolePerUserPlanNotice": "Ваш план поддерживает только одну роль каждого пользователя.", + "singleRolePerUserEditionNotice": "Эта редакция поддерживает только одну роль для каждого пользователя.", "roles": "Роли", "accessUsersRoles": "Управление пользователями и ролями", "accessUsersRolesDescription": "Пригласить пользователей и добавить их в роли для управления доступом к организации", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Введите токен настройки из консоли сервера.", "setupTokenRequired": "Токен настройки обязателен", "actionUpdateSite": "Обновить сайт", + "actionResetSiteBandwidth": "Сброс пропускной способности организации", "actionListSiteRoles": "Список разрешенных ролей сайта", "actionCreateResource": "Создать ресурс", "actionDeleteResource": "Удалить ресурс", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Удалить пользователя", "actionListUsers": "Список пользователей", "actionAddUserRole": "Добавить роль пользователя", + "actionSetUserOrgRoles": "Установка ролей пользователей", "actionGenerateAccessToken": "Сгенерировать токен доступа", "actionDeleteAccessToken": "Удалить токен доступа", "actionListAccessTokens": "Список токенов доступа", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Роли", "sidebarShareableLinks": "Ссылки", "sidebarApiKeys": "API ключи", + "sidebarProvisioning": "Подготовка", "sidebarSettings": "Настройки", "sidebarAllUsers": "Все пользователи", "sidebarIdentityProviders": "Поставщики удостоверений", @@ -1889,6 +1948,40 @@ "exitNode": "Узел выхода", "country": "Страна", "rulesMatchCountry": "В настоящее время основано на исходном IP", + "region": "Регион", + "selectRegion": "Выберите регион", + "searchRegions": "Поиск регионов...", + "noRegionFound": "Регион не найден.", + "rulesMatchRegion": "Выберите региональную группу стран", + "rulesErrorInvalidRegion": "Некорректный регион", + "rulesErrorInvalidRegionDescription": "Пожалуйста, выберите корректный регион.", + "regionAfrica": "Африка", + "regionNorthernAfrica": "Северная Африка", + "regionEasternAfrica": "Восточная Африка", + "regionMiddleAfrica": "Центральная Африка", + "regionSouthernAfrica": "Южная Африка", + "regionWesternAfrica": "Западная Африка", + "regionAmericas": "Америка", + "regionCaribbean": "Карибы", + "regionCentralAmerica": "Центральная Америка", + "regionSouthAmerica": "Южная Америка", + "regionNorthernAmerica": "Северная Америка", + "regionAsia": "Азия", + "regionCentralAsia": "Центральная Азия", + "regionEasternAsia": "Восточная Азия", + "regionSouthEasternAsia": "Юго-Восточная Азия", + "regionSouthernAsia": "Южная Азия", + "regionWesternAsia": "Западная Азия", + "regionEurope": "Европа", + "regionEasternEurope": "Восточная Европа", + "regionNorthernEurope": "Северная Европа", + "regionSouthernEurope": "Южная Европа", + "regionWesternEurope": "Западная Европа", + "regionOceania": "Океания", + "regionAustraliaAndNewZealand": "Австралия и Новая Зеландия", + "regionMelanesia": "Меланезия", + "regionMicronesia": "Микронезия", + "regionPolynesia": "Полинезия", "managedSelfHosted": { "title": "Управляемый с самовывоза", "description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками", @@ -1937,6 +2030,25 @@ "invalidValue": "Неверное значение", "idpTypeLabel": "Тип поставщика удостоверений", "roleMappingExpressionPlaceholder": "например, contains(groups, 'admin') && 'Admin' || 'Member'", + "roleMappingModeFixedRoles": "Фиксированные роли", + "roleMappingModeMappingBuilder": "Сопоставляющий конструктор", + "roleMappingModeRawExpression": "Сырое выражение", + "roleMappingFixedRolesPlaceholderSelect": "Выберите одну или несколько ролей", + "roleMappingFixedRolesPlaceholderFreeform": "Тип имен ролей (точное совпадение по организации)", + "roleMappingFixedRolesDescriptionSameForAll": "Назначить одну и ту же роль, которая установлена каждому автообеспеченному пользователю.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "Для политик по умолчанию, введите имена ролей, которые существуют в каждой организации, где пользователи предоставлены. Имена должны соответствовать точно.", + "roleMappingClaimPath": "Путь к заявлению", + "roleMappingClaimPathPlaceholder": "группы", + "roleMappingClaimPathDescription": "Путь в полезной нагрузке токенов, который содержит исходные значения (например, группы).", + "roleMappingMatchValue": "Значение матча", + "roleMappingAssignRoles": "Назначить роли", + "roleMappingAddMappingRule": "Добавить правило сопоставления", + "roleMappingRawExpressionResultDescription": "Выражение должно быть оценено к строке или строковому массиву.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Выражение должно быть оценено строке (название одной роли).", + "roleMappingMatchValuePlaceholder": "Значение совпадения (например: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Введите имена ролей (точное по организациям)", + "roleMappingBuilderFreeformRowHint": "Имена ролей должны соответствовать роли в каждой целевой организации.", + "roleMappingRemoveRule": "Удалить", "idpGoogleConfiguration": "Конфигурация Google", "idpGoogleConfigurationDescription": "Настройка учетных данных Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Как долго сохранять журналы доступа", "logRetentionActionLabel": "Сохранение журнала действий", "logRetentionActionDescription": "Как долго хранить журналы действий", + "logRetentionConnectionLabel": "Сохранение журнала подключений", + "logRetentionConnectionDescription": "Как долго хранить журналы подключений", "logRetentionDisabled": "Отключено", "logRetention3Days": "3 дня", "logRetention7Days": "7 дней", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Конец следующего года", "actionLogsDescription": "Просмотр истории действий, выполненных в этой организации", "accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации", + "connectionLogs": "Журнал подключений", + "connectionLogsDescription": "Просмотр журналов подключения туннелей в этой организации", + "sidebarLogsConnection": "Журнал подключений", + "sidebarLogsStreaming": "Вещание", + "sourceAddress": "Адрес источника", + "destinationAddress": "Адрес назначения", + "duration": "Продолжительность", "licenseRequiredToUse": "Требуется лицензия на Enterprise Edition или Pangolin Cloud для использования этой функции. Забронируйте демонстрацию или пробный POC.", "ossEnterpriseEditionRequired": "Enterprise Edition требуется для использования этой функции. Эта функция также доступна в Pangolin Cloud. Забронируйте демонстрацию или пробный POC.", "certResolver": "Резольвер сертификата", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Редактировать роль и включить опцию 'Требовать утверждения устройств'. Пользователям с этой ролью потребуется подтверждение администратора для новых устройств.", "approvalsEmptyStatePreviewDescription": "Предпросмотр: Если включено, ожидающие запросы на устройство появятся здесь для проверки", "approvalsEmptyStateButtonText": "Управление ролями", - "domainErrorTitle": "У нас возникли проблемы с проверкой вашего домена" + "domainErrorTitle": "У нас возникли проблемы с проверкой вашего домена", + "idpAdminAutoProvisionPoliciesTabHint": "Настройте сопоставление ролей и организационные политики на вкладке Настройки авто-предоставления.", + "streamingTitle": "Поток событий", + "streamingDescription": "Трансляция событий от вашей организации к внешним направлениям в режиме реального времени.", + "streamingUnnamedDestination": "Место назначения без имени", + "streamingNoUrlConfigured": "URL-адрес не настроен", + "streamingAddDestination": "Добавить место назначения", + "streamingHttpWebhookTitle": "HTTP вебхук", + "streamingHttpWebhookDescription": "Отправлять события на любую конечную точку HTTP с гибкой аутентификацией и шаблоном.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Потоковая передача событий к пакету хранения объектов, совместимому с S3.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Перенаправлять события непосредственно на ваш аккаунт в Datadog. Скоро будет доступно.", + "streamingTypePickerDescription": "Выберите тип назначения, чтобы начать.", + "streamingFailedToLoad": "Не удалось загрузить места назначения", + "streamingUnexpectedError": "Произошла непредвиденная ошибка.", + "streamingFailedToUpdate": "Не удалось обновить место назначения", + "streamingDeletedSuccess": "Адрес назначения успешно удален", + "streamingFailedToDelete": "Не удалось удалить место назначения", + "streamingDeleteTitle": "Удалить адрес назначения", + "streamingDeleteButtonText": "Удалить адрес назначения", + "streamingDeleteDialogAreYouSure": "Вы уверены, что хотите удалить", + "streamingDeleteDialogThisDestination": "это место назначения", + "streamingDeleteDialogPermanentlyRemoved": "? Все настройки будут удалены навсегда.", + "httpDestEditTitle": "Изменить адрес назначения", + "httpDestAddTitle": "Добавить HTTP адрес", + "httpDestEditDescription": "Обновление конфигурации для этого HTTP события потокового назначения.", + "httpDestAddDescription": "Настройте новую HTTP-конечную точку для получения событий вашей организации.", + "httpDestTabSettings": "Настройки", + "httpDestTabHeaders": "Заголовки", + "httpDestTabBody": "Тело", + "httpDestTabLogs": "Логи", + "httpDestNamePlaceholder": "Мой HTTP адрес назначения", + "httpDestUrlLabel": "URL назначения", + "httpDestUrlErrorHttpRequired": "URL должен использовать http или https", + "httpDestUrlErrorHttpsRequired": "Требуется HTTPS при развертывании облака", + "httpDestUrlErrorInvalid": "Введите действительный URL (например, https://example.com/webhook)", + "httpDestAuthTitle": "Аутентификация", + "httpDestAuthDescription": "Выберите, как запросы к вашей конечной точке аутентифицированы.", + "httpDestAuthNoneTitle": "Нет аутентификации", + "httpDestAuthNoneDescription": "Отправляет запросы без заголовка авторизации.", + "httpDestAuthBearerTitle": "Жетон носителя", + "httpDestAuthBearerDescription": "Добавляет заголовок Authorization: Bearer к каждому запросу.", + "httpDestAuthBearerPlaceholder": "Ваш ключ API или токен", + "httpDestAuthBasicTitle": "Базовая авторизация", + "httpDestAuthBasicDescription": "Добавляет Authorization: Basic header. Предоставьте учетные данные в качестве имени пользователя:password.", + "httpDestAuthBasicPlaceholder": "имя пользователя:пароль", + "httpDestAuthCustomTitle": "Пользовательский заголовок", + "httpDestAuthCustomDescription": "Укажите пользовательское имя заголовка HTTP и значение для аутентификации (например, X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Имя заголовка (например, X-API-ключ)", + "httpDestAuthCustomHeaderValuePlaceholder": "Значение заголовка", + "httpDestCustomHeadersTitle": "Пользовательские HTTP-заголовки", + "httpDestCustomHeadersDescription": "Добавляет пользовательские заголовки к каждому исходящему запросу. Полезно для статических маркеров или пользовательского типа содержимого. По умолчанию отправляется Content-Type: application/json.", + "httpDestNoHeadersConfigured": "Пользовательские заголовки не настроены. Нажмите \"Добавить заголовок\", чтобы добавить их.", + "httpDestHeaderNamePlaceholder": "Название заголовка", + "httpDestHeaderValuePlaceholder": "Значение", + "httpDestAddHeader": "Добавить заголовок", + "httpDestBodyTemplateTitle": "Пользовательский шаблон тела", + "httpDestBodyTemplateDescription": "Контролируйте структуру JSON приложения, отправленную на вашу конечную точку. Если отключено, для каждого события отправляется JSON объект по умолчанию.", + "httpDestEnableBodyTemplate": "Включить настраиваемый шаблон тела", + "httpDestBodyTemplateLabel": "Шаблон тела (JSON)", + "httpDestBodyTemplateHint": "Использовать шаблонные переменные для ссылки поля событий в вашей полезной нагрузке.", + "httpDestPayloadFormatTitle": "Формат нагрузки", + "httpDestPayloadFormatDescription": "Как события сериализуются в каждый орган запроса.", + "httpDestFormatJsonArrayTitle": "JSON массив", + "httpDestFormatJsonArrayDescription": "По одному запросу на каждую партию, тело является JSON-массивом. Совместим с большинством общих вебхуков и Датадог.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "По одному запросу на каждую партию, тело - это JSON, разделённый новой строкой, по одному объекту на строку, без внешнего массива. Требуется в Splunk HEC, Elastic / OpenSearch, и Grafana Loki.", + "httpDestFormatSingleTitle": "Одно событие на запрос", + "httpDestFormatSingleDescription": "Отправляет отдельный HTTP POST для каждого отдельного события. Используйте только для конечных точек, которые не могут обрабатывать пакеты.", + "httpDestLogTypesTitle": "Типы журналов", + "httpDestLogTypesDescription": "Выберите, какие типы журналов пересылаются в этот пункт назначения. Только включенные типы журналов будут транслированы.", + "httpDestAccessLogsTitle": "Журналы доступа", + "httpDestAccessLogsDescription": "Попытки доступа к ресурсам, включая аутентифицированные и отклоненные запросы.", + "httpDestActionLogsTitle": "Журнал действий", + "httpDestActionLogsDescription": "Административные меры, осуществляемые пользователями в рамках организации.", + "httpDestConnectionLogsTitle": "Журнал подключений", + "httpDestConnectionLogsDescription": "События связи с сайтами и туннелями, включая соединения и отключения.", + "httpDestRequestLogsTitle": "Запросить журналы", + "httpDestRequestLogsDescription": "Журналы запросов HTTP для проксируемых ресурсов, включая метод, путь и код ответа.", + "httpDestSaveChanges": "Сохранить изменения", + "httpDestCreateDestination": "Создать адрес назначения", + "httpDestUpdatedSuccess": "Адрес назначения успешно обновлен", + "httpDestCreatedSuccess": "Адрес назначения успешно создан", + "httpDestUpdateFailed": "Не удалось обновить место назначения", + "httpDestCreateFailed": "Не удалось создать место назначения" } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 362f891fb..e89778322 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -148,6 +148,11 @@ "createLink": "Bağlantı Oluştur", "resourcesNotFound": "Hiçbir kaynak bulunamadı", "resourceSearch": "Kaynak ara", + "machineSearch": "Makinaları ara", + "machinesSearch": "Makina müşteri...", + "machineNotFound": "Hiçbir makine bulunamadı", + "userDeviceSearch": "Kullanıcı cihazlarını ara", + "userDevicesSearch": "Kullanıcı cihazlarını ara...", "openMenu": "Menüyü Aç", "resource": "Kaynak", "title": "Başlık", @@ -323,6 +328,54 @@ "apiKeysDelete": "API Anahtarını Sil", "apiKeysManage": "API Anahtarlarını Yönet", "apiKeysDescription": "API anahtarları entegrasyon API'sini doğrulamak için kullanılır", + "provisioningKeysTitle": "Tedarik Anahtarı", + "provisioningKeysManage": "Tedarik Anahtarlarını Yönet", + "provisioningKeysDescription": "Tedarik anahtarları, organizasyonunuz için otomatik site sağlama işlemini doğrulamak için kullanılır.", + "provisioningManage": "Tedarik", + "provisioningDescription": "Tedarik anahtarlarını yönetin ve onay bekleyen siteleri gözden geçirin.", + "pendingSites": "Bekleyen Siteler", + "siteApproveSuccess": "Site başarıyla onaylandı", + "siteApproveError": "Site onaylanırken hata oluştu", + "provisioningKeys": "Tedarik Anahtarları", + "searchProvisioningKeys": "Tedarik anahtarlarını ara...", + "provisioningKeysAdd": "Tedarik Anahtarı Üret", + "provisioningKeysErrorDelete": "Tedarik anahtarı silinirken hata oluştu", + "provisioningKeysErrorDeleteMessage": "Tedarik anahtarı silinirken hata oluştu", + "provisioningKeysQuestionRemove": "Bu tedarik anahtarını organizasyondan kaldırmak istediğinizden emin misiniz?", + "provisioningKeysMessageRemove": "Kaldırıldıktan sonra, anahtar site tedariki için artık kullanılamaz.", + "provisioningKeysDeleteConfirm": "Tedarik Anahtarını Silmeyi Onayla", + "provisioningKeysDelete": "Tedarik Anahtarını Sil", + "provisioningKeysCreate": "Tedarik Anahtarı Üret", + "provisioningKeysCreateDescription": "Organizasyon için yeni bir tedarik anahtarı oluşturun", + "provisioningKeysSeeAll": "Tüm tedarik anahtarlarını gör", + "provisioningKeysSave": "Tedarik anahtarını kaydet", + "provisioningKeysSaveDescription": "Bunu yalnızca bir kez görebileceksiniz. Güvenli bir yere kopyalayın.", + "provisioningKeysErrorCreate": "Tedarik anahtarı oluşturulurken hata oluştu", + "provisioningKeysList": "Yeni tedarik anahtarı", + "provisioningKeysMaxBatchSize": "Maksimum toplu iş boyutu", + "provisioningKeysUnlimitedBatchSize": "Sınırsız toplu iş boyutu (sınırlama yok)", + "provisioningKeysMaxBatchUnlimited": "Sınırsız", + "provisioningKeysMaxBatchSizeInvalid": "Geçerli bir maksimum toplu iş boyutu girin (1–1,000,000).", + "provisioningKeysValidUntil": "Geçerlilik tarihi", + "provisioningKeysValidUntilHint": "Son kullanım tarihi için boş bırakın.", + "provisioningKeysValidUntilInvalid": "Geçerli bir tarih ve saat girin.", + "provisioningKeysNumUsed": "Kullanım Sayısı", + "provisioningKeysLastUsed": "Son kullanım", + "provisioningKeysNoExpiry": "Son kullanma tarihi yok", + "provisioningKeysNeverUsed": "Asla", + "provisioningKeysEdit": "Tedarik Anahtarını Düzenle", + "provisioningKeysEditDescription": "Bu anahtar için maksimum toplu iş boyutunu ve son kullanma zamanını güncelleyin.", + "provisioningKeysApproveNewSites": "Yeni siteleri onayla", + "provisioningKeysApproveNewSitesDescription": "Bu anahtar ile kayıt olan siteleri otomatik olarak onayla.", + "provisioningKeysUpdateError": "Tedarik anahtarı güncellenirken hata oluştu", + "provisioningKeysUpdated": "Tedarik anahtarı güncellendi", + "provisioningKeysUpdatedDescription": "Değişiklikleriniz kaydedildi.", + "provisioningKeysBannerTitle": "Site Tedarik Anahtarları", + "provisioningKeysBannerDescription": "Tedarik anahtarı oluşturun ve ilk başlangıçta siteleri otomatik olarak oluşturmak için Newt konektörüyle kullanın — her site için ayrı kimlik bilgileri ayarlamaya gerek yoktur.", + "provisioningKeysBannerButtonText": "Daha fazla bilgi", + "pendingSitesBannerTitle": "Bekleyen Siteler", + "pendingSitesBannerDescription": "Tedarik anahtarı kullanarak bağlanan siteler burada incelenmek için görünür. Aktif hale gelmeden ve kaynaklarınıza erişim kazanmadan önce her siteyi onaylayın.", + "pendingSitesBannerButtonText": "Daha fazla bilgi", "apiKeysSettings": "{apiKeyName} Ayarları", "userTitle": "Tüm Kullanıcıları Yönet", "userDescription": "Sistemdeki tüm kullanıcıları görün ve yönetin", @@ -509,9 +562,12 @@ "userSaved": "Kullanıcı kaydedildi", "userSavedDescription": "Kullanıcı güncellenmiştir.", "autoProvisioned": "Otomatik Sağlandı", + "autoProvisionSettings": "Otomatik Tedarik Ayarları", "autoProvisionedDescription": "Bu kullanıcının kimlik sağlayıcısı tarafından otomatik olarak yönetilmesine izin ver", "accessControlsDescription": "Bu kullanıcının organizasyonda neleri erişebileceğini ve yapabileceğini yönetin", "accessControlsSubmit": "Erişim Kontrollerini Kaydet", + "singleRolePerUserPlanNotice": "Planınız yalnızca kullanıcı başına bir rol desteler.", + "singleRolePerUserEditionNotice": "Bu sürüm yalnızca kullanıcı başına bir rol destekler.", "roles": "Roller", "accessUsersRoles": "Kullanıcılar ve Roller Yönetin", "accessUsersRolesDescription": "Kullanıcılara davet gönderin ve organizasyona erişimi yönetmek için rollere ekleyin", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.", "setupTokenRequired": "Kurulum simgesi gerekli", "actionUpdateSite": "Siteyi Güncelle", + "actionResetSiteBandwidth": "Organizasyon Bant Genişliğini Sıfırla", "actionListSiteRoles": "İzin Verilen Site Rolleri Listele", "actionCreateResource": "Kaynak Oluştur", "actionDeleteResource": "Kaynağı Sil", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "Kullanıcıyı Kaldır", "actionListUsers": "Kullanıcıları Listele", "actionAddUserRole": "Kullanıcı Rolü Ekle", + "actionSetUserOrgRoles": "Kullanıcı Rolleri Belirle", "actionGenerateAccessToken": "Erişim Jetonu Oluştur", "actionDeleteAccessToken": "Erişim Jetonunu Sil", "actionListAccessTokens": "Erişim Jetonlarını Listele", @@ -1264,6 +1322,7 @@ "sidebarRoles": "Roller", "sidebarShareableLinks": "Bağlantılar", "sidebarApiKeys": "API Anahtarları", + "sidebarProvisioning": "Tedarik", "sidebarSettings": "Ayarlar", "sidebarAllUsers": "Tüm Kullanıcılar", "sidebarIdentityProviders": "Kimlik Sağlayıcılar", @@ -1889,6 +1948,40 @@ "exitNode": "Çıkış Düğümü", "country": "Ülke", "rulesMatchCountry": "Şu anda kaynak IP'ye dayanarak", + "region": "Bölge", + "selectRegion": "Bölgeyi seçin", + "searchRegions": "Bölgeleri ara...", + "noRegionFound": "Bölge bulunamadı.", + "rulesMatchRegion": "Başka ülkelerin bölgesel gruplandırmasını seçin", + "rulesErrorInvalidRegion": "Geçersiz bölge", + "rulesErrorInvalidRegionDescription": "Lütfen geçerli bir bölge seçin.", + "regionAfrica": "Afrika", + "regionNorthernAfrica": "Kuzey Afrika", + "regionEasternAfrica": "Doğu Afrika", + "regionMiddleAfrica": "Orta Afrika", + "regionSouthernAfrica": "Güney Afrika", + "regionWesternAfrica": "Batı Afrika", + "regionAmericas": "Amerika", + "regionCaribbean": "Karayipler", + "regionCentralAmerica": "Orta Amerika", + "regionSouthAmerica": "Güney Amerika", + "regionNorthernAmerica": "Kuzey Amerika", + "regionAsia": "Asya", + "regionCentralAsia": "Orta Asya", + "regionEasternAsia": "Doğu Asya", + "regionSouthEasternAsia": "Güneydoğu Asya", + "regionSouthernAsia": "Güney Asya", + "regionWesternAsia": "Batı Asya", + "regionEurope": "Avrupa", + "regionEasternEurope": "Doğu Avrupa", + "regionNorthernEurope": "Kuzey Avrupa", + "regionSouthernEurope": "Güney Avrupa", + "regionWesternEurope": "Batı Avrupa", + "regionOceania": "Okyanusya", + "regionAustraliaAndNewZealand": "Avustralya ve Yeni Zelanda", + "regionMelanesia": "Melanezya", + "regionMicronesia": "Mikronezya", + "regionPolynesia": "Polinezya", "managedSelfHosted": { "title": "Yönetilen Self-Hosted", "description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu", @@ -1937,6 +2030,25 @@ "invalidValue": "Geçersiz değer", "idpTypeLabel": "Kimlik Sağlayıcı Türü", "roleMappingExpressionPlaceholder": "örn., contains(gruplar, 'yönetici') && 'Yönetici' || 'Üye'", + "roleMappingModeFixedRoles": "Sabit Roller", + "roleMappingModeMappingBuilder": "Harita Oluşturucu", + "roleMappingModeRawExpression": "Ham İfade", + "roleMappingFixedRolesPlaceholderSelect": "Bir veya daha fazla rol seçin", + "roleMappingFixedRolesPlaceholderFreeform": "Rol isimlerini yazın (organizasyon başına tam eşleşme)", + "roleMappingFixedRolesDescriptionSameForAll": "Her otomatik tedarik edilmiş kullanıcıya aynı rol setini atayın.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "Varsayılan politikalar için, kullanıcıların sağlandığı her organizasyonda mevcut olan rol isimlerini yazın. İsimler tam olarak eşleşmelidir.", + "roleMappingClaimPath": "Hak Talep Yolu", + "roleMappingClaimPathPlaceholder": "gruplar", + "roleMappingClaimPathDescription": "Kaynak değerleri içeren belirteç yükündeki yol (örneğin, gruplar).", + "roleMappingMatchValue": "Eşleme Değeri", + "roleMappingAssignRoles": "Rolleri Ata", + "roleMappingAddMappingRule": "Eşleme Kuralı Ekle", + "roleMappingRawExpressionResultDescription": "İfade bir string veya string dizisine değerlendirilmelidir.", + "roleMappingRawExpressionResultDescriptionSingleRole": "İfade bir string (tek rol ismi) olarak değerlendirilmelidir.", + "roleMappingMatchValuePlaceholder": "Eşleme değeri (örneğin: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Rol isimlerini yazın (organizasyon başına tam eşleşme)", + "roleMappingBuilderFreeformRowHint": "Rol isimleri her hedef organizasyondaki bir rol ile eşleşmelidir.", + "roleMappingRemoveRule": "Kaldır", "idpGoogleConfiguration": "Google Yapılandırması", "idpGoogleConfigurationDescription": "Google OAuth2 kimlik bilgilerinizi yapılandırın", "idpGoogleClientIdDescription": "Google OAuth2 İstemci Kimliğiniz", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "Erişim günlüklerini ne kadar süre tutacağını belirle", "logRetentionActionLabel": "Eylem Günlüğü Saklama", "logRetentionActionDescription": "Eylem günlüklerini ne kadar süre tutacağını belirle", + "logRetentionConnectionLabel": "Bağlantı kayıtlarını ne kadar süre saklayacağınız", + "logRetentionConnectionDescription": "Bağlantı kayıtlarını ne kadar süre saklayacağınız", "logRetentionDisabled": "Devre Dışı", "logRetention3Days": "3 gün", "logRetention7Days": "7 gün", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu", "actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin", "accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin", + "connectionLogs": "Bağlantı Kayıtları", + "connectionLogsDescription": "Bu organizasyondaki tüneller için bağlantı geçmişine bakın", + "sidebarLogsConnection": "Bağlantı Kayıtları", + "sidebarLogsStreaming": "Akış", + "sourceAddress": "Kaynak Adresi", + "destinationAddress": "Hedef Adresi", + "duration": "Süre", "licenseRequiredToUse": "Bu özelliği kullanmak için bir Enterprise Edition lisansı veya Pangolin Cloud gereklidir. Tanıtım veya POC denemesi ayarlayın.", "ossEnterpriseEditionRequired": "Bu özelliği kullanmak için Enterprise Edition gereklidir. Bu özellik ayrıca Pangolin Cloud’da da mevcuttur. Tanıtım veya POC denemesi ayarlayın.", "certResolver": "Sertifika Çözücü", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "Bir rolü düzenleyin ve 'Cihaz Onaylarını Gerektir' seçeneğini etkinleştirin. Bu role sahip kullanıcıların yeni cihazlar için yönetici onayına ihtiyacı olacaktır.", "approvalsEmptyStatePreviewDescription": "Önizleme: Etkinleştirildiğinde, bekleyen cihaz talepleri incelenmek üzere burada görünecektir.", "approvalsEmptyStateButtonText": "Rolleri Yönet", - "domainErrorTitle": "Alan adınızı doğrulamada sorun yaşıyoruz" + "domainErrorTitle": "Alan adınızı doğrulamada sorun yaşıyoruz", + "idpAdminAutoProvisionPoliciesTabHint": "Rol eşleme ve organizasyon politikalarını Otomatik Tedarik Ayarları sekmesinde yapılandırın.", + "streamingTitle": "Olay Akışı", + "streamingDescription": "Olayları organizasyonunuzdan dış hedeflere gerçek zamanlı olarak iletin.", + "streamingUnnamedDestination": "Adsız hedef", + "streamingNoUrlConfigured": "URL yapılandırılmadı", + "streamingAddDestination": "Hedef Ekle", + "streamingHttpWebhookTitle": "HTTP Webhook", + "streamingHttpWebhookDescription": "Esnek kimlik doğrulama ve şablon oluşturmayla her HTTP uç noktasına olaylar gönderin.", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "Olayları S3 uyumlu bir nesne depolama kovasına iletin. Yakında gelicek.", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "Olayları doğrudan Datadog hesabınıza iletin. Yakında gelicek.", + "streamingTypePickerDescription": "Başlamak için bir hedef türü seçin.", + "streamingFailedToLoad": "Hedefler yüklenemedi", + "streamingUnexpectedError": "Beklenmeyen bir hata oluştu.", + "streamingFailedToUpdate": "Hedef güncellenemedi", + "streamingDeletedSuccess": "Hedef başarıyla silindi", + "streamingFailedToDelete": "Hedef silinemedi", + "streamingDeleteTitle": "Hedefi Sil", + "streamingDeleteButtonText": "Hedefi Sil", + "streamingDeleteDialogAreYouSure": "Silmek istediğinizden emin misiniz", + "streamingDeleteDialogThisDestination": "bu hedefi", + "streamingDeleteDialogPermanentlyRemoved": "? Tüm yapılandırma kalıcı olarak kaldırılacak.", + "httpDestEditTitle": "Hedefi Düzenle", + "httpDestAddTitle": "HTTP Hedefi Ekle", + "httpDestEditDescription": "Bu HTTP olay akışı hedefine yapılandırmayı güncelleyin.", + "httpDestAddDescription": "Organizasyonunuzun olaylarını almak için yeni bir HTTP uç noktası yapılandırın.", + "httpDestTabSettings": "Ayarlar", + "httpDestTabHeaders": "Başlıklar", + "httpDestTabBody": "Gövde", + "httpDestTabLogs": "Kayıtlar", + "httpDestNamePlaceholder": "Benim HTTP hedefim", + "httpDestUrlLabel": "Hedef URL", + "httpDestUrlErrorHttpRequired": "URL http veya https kullanmalıdır", + "httpDestUrlErrorHttpsRequired": "Bulut dağıtımlarında HTTPS gereklidir", + "httpDestUrlErrorInvalid": "Geçerli bir URL girin (örn. https://example.com/webhook)", + "httpDestAuthTitle": "Kimlik Doğrulama", + "httpDestAuthDescription": "Uç noktanıza yapılan isteklerin nasıl kimlik doğrulandığını seçin.", + "httpDestAuthNoneTitle": "Kimlik Doğrulama Yok", + "httpDestAuthNoneDescription": "Yetkilendirme başlığı olmadan istekler gönderir.", + "httpDestAuthBearerTitle": "Taşıyıcı Jetonu", + "httpDestAuthBearerDescription": "Her isteğe bir Yetkilendirme: Taşıyıcı başlığı ekler.", + "httpDestAuthBearerPlaceholder": "API anahtarınız veya jetonunuz", + "httpDestAuthBasicTitle": "Temel Kimlik Doğrulama", + "httpDestAuthBasicDescription": "Authorization: Temel başlığı ekler. Yetkilendirmeleri kullanıcı adı:şifre olarak sağlayın.", + "httpDestAuthBasicPlaceholder": "kullanıcı adı:şifre", + "httpDestAuthCustomTitle": "Özel Başlık", + "httpDestAuthCustomDescription": "Kimlik doğrulama için özel bir HTTP başlık adı ve değer belirtin (örn. X-API-Key).", + "httpDestAuthCustomHeaderNamePlaceholder": "Başlık adı (örn. X-API-Key)", + "httpDestAuthCustomHeaderValuePlaceholder": "Başlık değeri", + "httpDestCustomHeadersTitle": "Özel HTTP Başlıkları", + "httpDestCustomHeadersDescription": "Her giden isteğe özel başlıklar ekleyin. Statik jetonlar veya özel bir İçerik Türü için kullanışlıdır. Varsayılan olarak İçerik Türü: application/json gönderilir.", + "httpDestNoHeadersConfigured": "Özel başlık yapılandırılmamış. Bir tane eklemek için \"Başlık Ekle\"ye tıklayın.", + "httpDestHeaderNamePlaceholder": "Başlık adı", + "httpDestHeaderValuePlaceholder": "Değer", + "httpDestAddHeader": "Başlık Ekle", + "httpDestBodyTemplateTitle": "Özel Gövde Şablonu", + "httpDestBodyTemplateDescription": "Uç noktanıza gönderilen JSON yük yapısını kontrol edin. Devre dışı bırakılırsa, her olay için varsayılan bir JSON nesnesi gönderilir.", + "httpDestEnableBodyTemplate": "Özel gövde şablonunu etkinleştir", + "httpDestBodyTemplateLabel": "Gövde Şablonu (JSON)", + "httpDestBodyTemplateHint": "Yükünüzdeki olay alanlarına atıfta bulunmak için şablon değişkenlerini kullanın.", + "httpDestPayloadFormatTitle": "Yük Formatı", + "httpDestPayloadFormatDescription": "Her bir istek gövdesine olayların nasıl serileştirildiği.", + "httpDestFormatJsonArrayTitle": "JSON Dizisi", + "httpDestFormatJsonArrayDescription": "Her bir toplu işte bir istek, gövde bir JSON dizisidir. Çoğu genel webhook ve Datadog ile uyumludur.", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "Her bir toplu işte bir istek, gövde satırlarla ayrılmış JSON'dur - her satıra bir nesne, dış dizi yoktur. Splunk HEC, Elastic / OpenSearch ve Grafana Loki tarafından gereklidir.", + "httpDestFormatSingleTitle": "Her İstek Başına Bir Olay", + "httpDestFormatSingleDescription": "Her olay için ayrı bir HTTP POST gönderir. Toplu işlere yetkemeyen uç noktalar için kullanın.", + "httpDestLogTypesTitle": "Kayıt Türleri", + "httpDestLogTypesDescription": "Bu hedefe hangi kayıt türlerinin iletileceğini seçin. Yalnızca etkin kayıt türleri yayınlanacaktır.", + "httpDestAccessLogsTitle": "Erişim Kayıtları", + "httpDestAccessLogsDescription": "Kimlik doğrulanmış ve reddedilen talepler dahil kaynak erişim denemeleri.", + "httpDestActionLogsTitle": "Eylem Kayıtları", + "httpDestActionLogsDescription": "Kullanıcılar tarafından organizasyon içerisinde yapılan yönetici eylemleri.", + "httpDestConnectionLogsTitle": "Bağlantı Kayıtları", + "httpDestConnectionLogsDescription": "Site ve tünel bağlantı olayları, bağlantılar ve bağlantı kesilmeleri dahil.", + "httpDestRequestLogsTitle": "İstek Kayıtları", + "httpDestRequestLogsDescription": "Yönlendirilmiş kaynaklar için HTTP istek kayıtları, yöntem, yol ve yanıt kodu dahil.", + "httpDestSaveChanges": "Değişiklikleri Kaydet", + "httpDestCreateDestination": "Hedef Oluştur", + "httpDestUpdatedSuccess": "Hedef başarıyla güncellendi", + "httpDestCreatedSuccess": "Hedef başarıyla oluşturuldu", + "httpDestUpdateFailed": "Hedef güncellenemedi", + "httpDestCreateFailed": "Hedef oluşturulamadı" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index a7f2682fa..07ffe4d5b 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -148,6 +148,11 @@ "createLink": "创建链接", "resourcesNotFound": "找不到资源", "resourceSearch": "搜索资源", + "machineSearch": "搜索机", + "machinesSearch": "搜索机器客户端...", + "machineNotFound": "未找到任何机", + "userDeviceSearch": "搜索用户设备", + "userDevicesSearch": "搜索用户设备...", "openMenu": "打开菜单", "resource": "资源", "title": "标题", @@ -323,6 +328,54 @@ "apiKeysDelete": "删除 API 密钥", "apiKeysManage": "管理 API 密钥", "apiKeysDescription": "API 密钥用于认证集成 API", + "provisioningKeysTitle": "置备密钥", + "provisioningKeysManage": "管理置备键", + "provisioningKeysDescription": "置备密钥用于验证您组织的自动站点配置。", + "provisioningManage": "置备中", + "provisioningDescription": "管理预配键和审查等待批准的站点。", + "pendingSites": "待定站点", + "siteApproveSuccess": "站点批准成功", + "siteApproveError": "批准站点出错", + "provisioningKeys": "置备键", + "searchProvisioningKeys": "搜索配备密钥...", + "provisioningKeysAdd": "生成置备键", + "provisioningKeysErrorDelete": "删除预配键时出错", + "provisioningKeysErrorDeleteMessage": "删除预配键时出错", + "provisioningKeysQuestionRemove": "您确定要从组织中删除此预配键吗?", + "provisioningKeysMessageRemove": "一旦移除,密钥不能再用于站点预配。", + "provisioningKeysDeleteConfirm": "确认删除置备键", + "provisioningKeysDelete": "删除置备键", + "provisioningKeysCreate": "生成置备键", + "provisioningKeysCreateDescription": "为组织生成一个新的预置密钥", + "provisioningKeysSeeAll": "查看所有预配键", + "provisioningKeysSave": "保存预配键", + "provisioningKeysSaveDescription": "您只能看到一次。复制它到一个安全的地方。", + "provisioningKeysErrorCreate": "创建预配键时出错", + "provisioningKeysList": "新建预配键", + "provisioningKeysMaxBatchSize": "最大批量大小", + "provisioningKeysUnlimitedBatchSize": "无限批量大小(无限制)", + "provisioningKeysMaxBatchUnlimited": "无限制", + "provisioningKeysMaxBatchSizeInvalid": "输入一个有效的最大批处理大小(1-1,000,000)。", + "provisioningKeysValidUntil": "有效期至", + "provisioningKeysValidUntilHint": "留空为无过期。", + "provisioningKeysValidUntilInvalid": "输入一个有效的日期和时间。", + "provisioningKeysNumUsed": "使用的时间", + "provisioningKeysLastUsed": "上次使用", + "provisioningKeysNoExpiry": "没有过期", + "provisioningKeysNeverUsed": "永不过期", + "provisioningKeysEdit": "编辑置备键", + "provisioningKeysEditDescription": "更新此密钥的最大批量大小和过期时间。", + "provisioningKeysApproveNewSites": "批准新站点", + "provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的站点。", + "provisioningKeysUpdateError": "更新预配键时出错", + "provisioningKeysUpdated": "置备密钥已更新", + "provisioningKeysUpdatedDescription": "您的更改已保存。", + "provisioningKeysBannerTitle": "站点置备密钥", + "provisioningKeysBannerDescription": "生成一个预配键并使用它来在首次启动时自动创建站点——无需为每个站点设置单独的凭证。", + "provisioningKeysBannerButtonText": "了解更多", + "pendingSitesBannerTitle": "待定站点", + "pendingSitesBannerDescription": "使用预配键连接的站点会出现在这里供审核。在站点开始运行之前批准并获取对您资源的访问权限。", + "pendingSitesBannerButtonText": "了解更多", "apiKeysSettings": "{apiKeyName} 设置", "userTitle": "管理所有用户", "userDescription": "查看和管理系统中的所有用户", @@ -509,9 +562,12 @@ "userSaved": "用户已保存", "userSavedDescription": "用户已更新。", "autoProvisioned": "自动设置", + "autoProvisionSettings": "自动提供设置", "autoProvisionedDescription": "允许此用户由身份提供商自动管理", "accessControlsDescription": "管理此用户在组织中可以访问和做什么", "accessControlsSubmit": "保存访问控制", + "singleRolePerUserPlanNotice": "您的计划仅支持每个用户一个角色。", + "singleRolePerUserEditionNotice": "此版本仅支持每个用户一个角色。", "roles": "角色", "accessUsersRoles": "管理用户和角色", "accessUsersRolesDescription": "邀请用户加入角色来管理访问组织", @@ -1119,6 +1175,7 @@ "setupTokenDescription": "从服务器控制台输入设置令牌。", "setupTokenRequired": "需要设置令牌", "actionUpdateSite": "更新站点", + "actionResetSiteBandwidth": "重置组织带宽", "actionListSiteRoles": "允许站点角色列表", "actionCreateResource": "创建资源", "actionDeleteResource": "删除资源", @@ -1148,6 +1205,7 @@ "actionRemoveUser": "删除用户", "actionListUsers": "列出用户", "actionAddUserRole": "添加用户角色", + "actionSetUserOrgRoles": "设置用户角色", "actionGenerateAccessToken": "生成访问令牌", "actionDeleteAccessToken": "删除访问令牌", "actionListAccessTokens": "访问令牌", @@ -1264,6 +1322,7 @@ "sidebarRoles": "角色", "sidebarShareableLinks": "链接", "sidebarApiKeys": "API密钥", + "sidebarProvisioning": "置备中", "sidebarSettings": "设置", "sidebarAllUsers": "所有用户", "sidebarIdentityProviders": "身份提供商", @@ -1889,6 +1948,40 @@ "exitNode": "出口节点", "country": "国家", "rulesMatchCountry": "当前基于源 IP", + "region": "地区", + "selectRegion": "选择区域", + "searchRegions": "搜索区域...", + "noRegionFound": "未找到区域。", + "rulesMatchRegion": "选择一个区域国家组", + "rulesErrorInvalidRegion": "无效区域", + "rulesErrorInvalidRegionDescription": "请选择一个有效的区域。", + "regionAfrica": "非洲", + "regionNorthernAfrica": "B. 北非地区", + "regionEasternAfrica": "东部非洲", + "regionMiddleAfrica": "中东", + "regionSouthernAfrica": "D. 南 非", + "regionWesternAfrica": "D. 西部非洲", + "regionAmericas": "Americas", + "regionCaribbean": "加勒比", + "regionCentralAmerica": "中美洲:", + "regionSouthAmerica": "南 非", + "regionNorthernAmerica": "北美洲:", + "regionAsia": "亚洲", + "regionCentralAsia": "B. 亚 洲", + "regionEasternAsia": "东亚", + "regionSouthEasternAsia": "D. 东南亚区域", + "regionSouthernAsia": "D. 亚 洲", + "regionWesternAsia": "西亚", + "regionEurope": "欧洲", + "regionEasternEurope": "D. 欧 洲", + "regionNorthernEurope": "北欧洲", + "regionSouthernEurope": "南欧洲", + "regionWesternEurope": "西欧洲", + "regionOceania": "Oceania", + "regionAustraliaAndNewZealand": "澳大利亚和新西兰", + "regionMelanesia": "Melanesia", + "regionMicronesia": "Micronesia", + "regionPolynesia": "Polynesia", "managedSelfHosted": { "title": "托管自托管", "description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器", @@ -1937,6 +2030,25 @@ "invalidValue": "无效的值", "idpTypeLabel": "身份提供者类型", "roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'", + "roleMappingModeFixedRoles": "固定角色", + "roleMappingModeMappingBuilder": "映射构建器", + "roleMappingModeRawExpression": "原始表达式", + "roleMappingFixedRolesPlaceholderSelect": "选择一个或多个角色", + "roleMappingFixedRolesPlaceholderFreeform": "输入角色名称 (每个组织确切匹配)", + "roleMappingFixedRolesDescriptionSameForAll": "将相同的角色分配给每个自动配备的用户。", + "roleMappingFixedRolesDescriptionDefaultPolicy": "对于缺省策略,每个提供用户的组织中存在的角色名称类型。名称必须完全匹配。", + "roleMappingClaimPath": "认领路径", + "roleMappingClaimPathPlaceholder": "组", + "roleMappingClaimPathDescription": "包含源值的 token 有效负载路径 (例如组)。", + "roleMappingMatchValue": "匹配值", + "roleMappingAssignRoles": "分配角色", + "roleMappingAddMappingRule": "添加映射规则", + "roleMappingRawExpressionResultDescription": "表达式必须值为字符串或字符串。", + "roleMappingRawExpressionResultDescriptionSingleRole": "表达式必须计算到字符串(单个角色名称)。", + "roleMappingMatchValuePlaceholder": "匹配值(例如: 管理员)", + "roleMappingAssignRolesPlaceholderFreeform": "输入角色名称 (每个组织确切)", + "roleMappingBuilderFreeformRowHint": "角色名称必须匹配每个目标组织的角色。", + "roleMappingRemoveRule": "删除", "idpGoogleConfiguration": "Google 配置", "idpGoogleConfigurationDescription": "配置 Google OAuth2 凭据", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -2333,6 +2445,8 @@ "logRetentionAccessDescription": "保留访问日志的时间", "logRetentionActionLabel": "动作日志保留", "logRetentionActionDescription": "保留操作日志的时间", + "logRetentionConnectionLabel": "连接日志保留", + "logRetentionConnectionDescription": "保留连接日志的时间", "logRetentionDisabled": "已禁用", "logRetention3Days": "3 天", "logRetention7Days": "7 天", @@ -2343,6 +2457,13 @@ "logRetentionEndOfFollowingYear": "下一年结束", "actionLogsDescription": "查看此机构执行的操作历史", "accessLogsDescription": "查看此机构资源的访问认证请求", + "connectionLogs": "连接日志", + "connectionLogsDescription": "查看此机构隧道的连接日志", + "sidebarLogsConnection": "连接日志", + "sidebarLogsStreaming": "流流", + "sourceAddress": "源地址", + "destinationAddress": "目的地址", + "duration": "期限", "licenseRequiredToUse": "使用此功能需要企业版许可证或Pangolin Cloud预约演示或POC试用。", "ossEnterpriseEditionRequired": "需要 Enterprise Edition 才能使用此功能。 此功能也可在 Pangolin Cloud上获取。 预订演示或POC 试用。", "certResolver": "证书解决器", @@ -2682,5 +2803,90 @@ "approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。", "approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核", "approvalsEmptyStateButtonText": "管理角色", - "domainErrorTitle": "我们在验证您的域名时遇到了问题" + "domainErrorTitle": "我们在验证您的域名时遇到了问题", + "idpAdminAutoProvisionPoliciesTabHint": "在 自动供应设置 选项卡上配置角色映射和组织策略。", + "streamingTitle": "事件流", + "streamingDescription": "实时将事件从您的组织流到外部目的地。", + "streamingUnnamedDestination": "未命名目标", + "streamingNoUrlConfigured": "未配置URL", + "streamingAddDestination": "添加目标", + "streamingHttpWebhookTitle": "HTTP Webhook", + "streamingHttpWebhookDescription": "将事件发送到任意HTTP端点并灵活验证和模板。", + "streamingS3Title": "Amazon S3", + "streamingS3Description": "将事件串流到 S3 兼容的对象存储桶。即将推出。", + "streamingDatadogTitle": "Datadog", + "streamingDatadogDescription": "直接转发事件到您的Datadog 帐户。即将推出。", + "streamingTypePickerDescription": "选择要开始的目标类型。", + "streamingFailedToLoad": "加载目的地失败", + "streamingUnexpectedError": "发生意外错误.", + "streamingFailedToUpdate": "更新目标失败", + "streamingDeletedSuccess": "目标删除成功", + "streamingFailedToDelete": "删除目标失败", + "streamingDeleteTitle": "删除目标", + "streamingDeleteButtonText": "删除目标", + "streamingDeleteDialogAreYouSure": "您确定要删除吗?", + "streamingDeleteDialogThisDestination": "这个目标", + "streamingDeleteDialogPermanentlyRemoved": "? 所有配置将被永久删除。", + "httpDestEditTitle": "编辑目标", + "httpDestAddTitle": "添加 HTTP 目标", + "httpDestEditDescription": "更新此 HTTP 事件流媒体目的地的配置。", + "httpDestAddDescription": "配置新的 HTTP 端点来接收您的组织事件。", + "httpDestTabSettings": "设置", + "httpDestTabHeaders": "信头", + "httpDestTabBody": "正文内容", + "httpDestTabLogs": "日志", + "httpDestNamePlaceholder": "我的 HTTP 目标", + "httpDestUrlLabel": "目标网址", + "httpDestUrlErrorHttpRequired": "URL 必须使用 http 或 https", + "httpDestUrlErrorHttpsRequired": "云端部署需要HTTPS", + "httpDestUrlErrorInvalid": "输入一个有效的 URL (例如,https://example.com/webhook)", + "httpDestAuthTitle": "认证", + "httpDestAuthDescription": "选择如何验证您的端点的请求。", + "httpDestAuthNoneTitle": "无身份验证", + "httpDestAuthNoneDescription": "在没有授权头的情况下发送请求。", + "httpDestAuthBearerTitle": "持有者令牌", + "httpDestAuthBearerDescription": "添加授权:每个请求的标题为 。", + "httpDestAuthBearerPlaceholder": "您的 API 密钥或令牌", + "httpDestAuthBasicTitle": "基本认证", + "httpDestAuthBasicDescription": "添加授权:基本 头。提供用户名:密码的凭据。", + "httpDestAuthBasicPlaceholder": "用户名:密码", + "httpDestAuthCustomTitle": "自定义标题", + "httpDestAuthCustomDescription": "指定自定义 HTTP 头名称和身份验证值 (例如,X-API 键)。", + "httpDestAuthCustomHeaderNamePlaceholder": "标题名称(例如X-API-键)", + "httpDestAuthCustomHeaderValuePlaceholder": "页眉值", + "httpDestCustomHeadersTitle": "自定义 HTTP 头", + "httpDestCustomHeadersDescription": "向每个输出请求添加自定义标题。用于静态令牌或自定义内容类型。默认情况下,内容类型:应用程序/json已发送。", + "httpDestNoHeadersConfigured": "未配置自定义头。单击\"添加头\"以添加一个。", + "httpDestHeaderNamePlaceholder": "标题名称", + "httpDestHeaderValuePlaceholder": "值", + "httpDestAddHeader": "添加标题", + "httpDestBodyTemplateTitle": "自定义实体模板", + "httpDestBodyTemplateDescription": "控制发送到您的端点的 JSON 有效载荷结构。如果禁用,将为每个事件发送一个 JSON 默认对象。", + "httpDestEnableBodyTemplate": "启用自定义实体模板", + "httpDestBodyTemplateLabel": "身体模板 (JSON)", + "httpDestBodyTemplateHint": "将模板变量用于您有效载荷中的参考事件字段。", + "httpDestPayloadFormatTitle": "有效载荷格式", + "httpDestPayloadFormatDescription": "事件如何序列化为每个请求实体。", + "httpDestFormatJsonArrayTitle": "JSON 数组", + "httpDestFormatJsonArrayDescription": "每批一个请求,实体是一个 JSON 数组。与大多数通用的 Web 钩子和数据兼容。", + "httpDestFormatNdjsonTitle": "NDJSON", + "httpDestFormatNdjsonDescription": "每批有一个请求,物体是换行符限制的 JSON ——每行一个对象,不是外部数组。 Sluk HEC、Elastic / OpenSearch和Grafana Loki所需。", + "httpDestFormatSingleTitle": "每个请求一个事件", + "httpDestFormatSingleDescription": "为每个事件单独发送一个 HTTP POST。仅用于无法处理批量的端点。", + "httpDestLogTypesTitle": "日志类型", + "httpDestLogTypesDescription": "选择转发到此目的地的日志类型。只有启用的日志类型才会被连续使用。", + "httpDestAccessLogsTitle": "访问日志", + "httpDestAccessLogsDescription": "资源访问尝试,包括已验证和拒绝的请求。", + "httpDestActionLogsTitle": "操作日志", + "httpDestActionLogsDescription": "组织内部用户采取的行政行动。", + "httpDestConnectionLogsTitle": "连接日志", + "httpDestConnectionLogsDescription": "站点和隧道连接事件,包括连接和断开连接。", + "httpDestRequestLogsTitle": "请求日志", + "httpDestRequestLogsDescription": "HTTP 请求代理资源日志,包括方法、路径和响应代码。", + "httpDestSaveChanges": "保存更改", + "httpDestCreateDestination": "创建目标", + "httpDestUpdatedSuccess": "目标已成功更新", + "httpDestCreatedSuccess": "目标创建成功", + "httpDestUpdateFailed": "更新目标失败", + "httpDestCreateFailed": "创建目标失败" } diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 1ae6a5156..8b9d05f53 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1091,6 +1091,7 @@ "actionRemoveUser": "刪除用戶", "actionListUsers": "列出用戶", "actionAddUserRole": "添加用戶角色", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "生成訪問令牌", "actionDeleteAccessToken": "刪除訪問令牌", "actionListAccessTokens": "訪問令牌", diff --git a/package.json b/package.json index 66c61c0e6..7d7b3df69 100644 --- a/package.json +++ b/package.json @@ -112,13 +112,13 @@ "reodotdev": "1.1.0", "resend": "6.9.2", "semver": "7.7.4", - "sshpk": "^1.18.0", + "sshpk": "1.18.0", "stripe": "20.4.1", "swagger-ui-express": "5.0.1", "tailwind-merge": "3.5.0", "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", - "use-debounce": "^10.1.0", + "use-debounce": "10.1.0", "uuid": "13.0.0", "vaul": "1.1.2", "visionscarto-world-atlas": "1.0.0", @@ -153,7 +153,7 @@ "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", - "@types/sshpk": "^1.17.4", + "@types/sshpk": "1.17.4", "@types/swagger-ui-express": "4.1.8", "@types/topojson-client": "3.1.5", "@types/ws": "8.18.1", diff --git a/public/third-party/dd.png b/public/third-party/dd.png new file mode 100644 index 000000000..598771157 Binary files /dev/null and b/public/third-party/dd.png differ diff --git a/public/third-party/s3.png b/public/third-party/s3.png new file mode 100644 index 000000000..f86959a93 Binary files /dev/null and b/public/third-party/s3.png differ diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 450e3f42b..213dab9d3 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -1,9 +1,10 @@ import { Request } from "express"; import { db } from "@server/db"; -import { userActions, roleActions, userOrgs } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { userActions, roleActions } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export enum ActionsEnum { createOrgUser = "createOrgUser", @@ -53,6 +54,8 @@ export enum ActionsEnum { listRoleResources = "listRoleResources", // listRoleActions = "listRoleActions", addUserRole = "addUserRole", + removeUserRole = "removeUserRole", + setUserOrgRoles = "setUserOrgRoles", // addUserSite = "addUserSite", // addUserAction = "addUserAction", // removeUserAction = "removeUserAction", @@ -109,6 +112,10 @@ export enum ActionsEnum { listApiKeyActions = "listApiKeyActions", listApiKeys = "listApiKeys", getApiKey = "getApiKey", + createSiteProvisioningKey = "createSiteProvisioningKey", + listSiteProvisioningKeys = "listSiteProvisioningKeys", + updateSiteProvisioningKey = "updateSiteProvisioningKey", + deleteSiteProvisioningKey = "deleteSiteProvisioningKey", getCertificate = "getCertificate", restartCertificate = "restartCertificate", billing = "billing", @@ -133,7 +140,11 @@ export enum ActionsEnum { exportLogs = "exportLogs", listApprovals = "listApprovals", updateApprovals = "updateApprovals", - signSshKey = "signSshKey" + signSshKey = "signSshKey", + createEventStreamingDestination = "createEventStreamingDestination", + updateEventStreamingDestination = "updateEventStreamingDestination", + deleteEventStreamingDestination = "deleteEventStreamingDestination", + listEventStreamingDestinations = "listEventStreamingDestinations" } export async function checkUserActionPermission( @@ -154,29 +165,16 @@ export async function checkUserActionPermission( } try { - let userOrgRoleId = req.userOrgRoleId; + let userOrgRoleIds = req.userOrgRoleIds; - // If userOrgRoleId is not available on the request, fetch it - if (userOrgRoleId === undefined) { - const userOrgRole = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, req.userOrgId!) - ) - ) - .limit(1); - - if (userOrgRole.length === 0) { + if (userOrgRoleIds === undefined) { + userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!); + if (userOrgRoleIds.length === 0) { throw createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ); } - - userOrgRoleId = userOrgRole[0].roleId; } // Check if the user has direct permission for the action in the current org @@ -187,7 +185,7 @@ export async function checkUserActionPermission( and( eq(userActions.userId, userId), eq(userActions.actionId, actionId), - eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org + eq(userActions.orgId, req.userOrgId!) ) ) .limit(1); @@ -196,14 +194,14 @@ export async function checkUserActionPermission( return true; } - // If no direct permission, check role-based permission + // If no direct permission, check role-based permission (any of user's roles) const roleActionPermission = await db .select() .from(roleActions) .where( and( eq(roleActions.actionId, actionId), - eq(roleActions.roleId, userOrgRoleId!), + inArray(roleActions.roleId, userOrgRoleIds), eq(roleActions.orgId, req.userOrgId!) ) ) diff --git a/server/auth/canUserAccessResource.ts b/server/auth/canUserAccessResource.ts index 161a0bee9..2c8911490 100644 --- a/server/auth/canUserAccessResource.ts +++ b/server/auth/canUserAccessResource.ts @@ -1,26 +1,29 @@ import { db } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { roleResources, userResources } from "@server/db"; export async function canUserAccessResource({ userId, resourceId, - roleId + roleIds }: { userId: string; resourceId: number; - roleId: number; + roleIds: number[]; }): Promise { - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) - ) - ) - .limit(1); + const roleResourceAccess = + roleIds.length > 0 + ? await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return true; diff --git a/server/auth/canUserAccessSiteResource.ts b/server/auth/canUserAccessSiteResource.ts index 959b0eff6..7e6ec9bb8 100644 --- a/server/auth/canUserAccessSiteResource.ts +++ b/server/auth/canUserAccessSiteResource.ts @@ -1,26 +1,29 @@ import { db } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { roleSiteResources, userSiteResources } from "@server/db"; export async function canUserAccessSiteResource({ userId, resourceId, - roleId + roleIds }: { userId: string; resourceId: number; - roleId: number; + roleIds: number[]; }): Promise { - const roleResourceAccess = await db - .select() - .from(roleSiteResources) - .where( - and( - eq(roleSiteResources.siteResourceId, resourceId), - eq(roleSiteResources.roleId, roleId) - ) - ) - .limit(1); + const roleResourceAccess = + roleIds.length > 0 + ? await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, resourceId), + inArray(roleSiteResources.roleId, roleIds) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return true; diff --git a/server/cleanup.ts b/server/cleanup.ts index 3c462f3f2..10e9f4cc3 100644 --- a/server/cleanup.ts +++ b/server/cleanup.ts @@ -1,4 +1,5 @@ import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage"; +import { flushConnectionLogToDb } from "#dynamic/routers/newt"; import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"; import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator"; import { cleanup as wsCleanup } from "#dynamic/routers/ws"; @@ -6,6 +7,7 @@ import { cleanup as wsCleanup } from "#dynamic/routers/ws"; async function cleanup() { await stopPingAccumulator(); await flushBandwidthToDb(); + await flushConnectionLogToDb(); await flushSiteBandwidthToDb(); await wsCleanup(); @@ -16,4 +18,4 @@ export async function initCleanup() { // Handle process termination process.on("SIGTERM", () => cleanup()); process.on("SIGINT", () => cleanup()); -} \ No newline at end of file +} diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 9366e32e1..86a0c0352 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -60,8 +60,7 @@ function createDb() { }) ); } else { - const maxReplicaConnections = - poolConfig?.max_replica_connections || 20; + const maxReplicaConnections = poolConfig?.max_replica_connections || 20; for (const conn of replicaConnections) { const replicaPool = createPool( conn.connection_string, @@ -91,4 +90,5 @@ export default db; export const primaryDb = db.$primary; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] ->[0]; \ No newline at end of file +>[0]; +export const DB_TYPE: "pg" | "sqlite" = "pg"; diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index c9d7cc907..4122fb5b5 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -7,7 +7,9 @@ import { bigint, real, text, - index + index, + primaryKey, + uniqueIndex } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; import { @@ -17,7 +19,9 @@ import { users, exitNodes, sessions, - clients + clients, + siteResources, + sites } from "./schema"; export const certificates = pgTable("certificates", { @@ -89,7 +93,9 @@ export const subscriptions = pgTable("subscriptions", { export const subscriptionItems = pgTable("subscriptionItems", { subscriptionItemId: serial("subscriptionItemId").primaryKey(), - stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { length: 255 }), + stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { + length: 255 + }), subscriptionId: varchar("subscriptionId", { length: 255 }) .notNull() .references(() => subscriptions.subscriptionId, { @@ -286,6 +292,7 @@ export const accessAuditLog = pgTable( actor: varchar("actor", { length: 255 }), actorId: varchar("actorId", { length: 255 }), resourceId: integer("resourceId"), + siteResourceId: integer("siteResourceId"), ip: varchar("ip", { length: 45 }), type: varchar("type", { length: 100 }).notNull(), action: boolean("action").notNull(), @@ -302,6 +309,45 @@ export const accessAuditLog = pgTable( ] ); +export const connectionAuditLog = pgTable( + "connectionAuditLog", + { + id: serial("id").primaryKey(), + sessionId: text("sessionId").notNull(), + siteResourceId: integer("siteResourceId").references( + () => siteResources.siteResourceId, + { onDelete: "cascade" } + ), + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }), + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + sourceAddr: text("sourceAddr").notNull(), + destAddr: text("destAddr").notNull(), + protocol: text("protocol").notNull(), + startedAt: integer("startedAt").notNull(), + endedAt: integer("endedAt"), + bytesTx: integer("bytesTx"), + bytesRx: integer("bytesRx") + }, + (table) => [ + index("idx_accessAuditLog_startedAt").on(table.startedAt), + index("idx_accessAuditLog_org_startedAt").on( + table.orgId, + table.startedAt + ), + index("idx_accessAuditLog_siteResourceId").on(table.siteResourceId) + ] +); + export const approvals = pgTable("approvals", { approvalId: serial("approvalId").primaryKey(), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds @@ -329,13 +375,89 @@ export const approvals = pgTable("approvals", { }); export const bannedEmails = pgTable("bannedEmails", { - email: varchar("email", { length: 255 }).primaryKey(), + email: varchar("email", { length: 255 }).primaryKey() }); export const bannedIps = pgTable("bannedIps", { - ip: varchar("ip", { length: 255 }).primaryKey(), + ip: varchar("ip", { length: 255 }).primaryKey() }); +export const siteProvisioningKeys = pgTable("siteProvisioningKeys", { + siteProvisioningKeyId: varchar("siteProvisioningKeyId", { + length: 255 + }).primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(), + lastChars: varchar("lastChars", { length: 4 }).notNull(), + createdAt: varchar("dateCreated", { length: 255 }).notNull(), + lastUsed: varchar("lastUsed", { length: 255 }), + maxBatchSize: integer("maxBatchSize"), // null = no limit + numUsed: integer("numUsed").notNull().default(0), + validUntil: varchar("validUntil", { length: 255 }), + approveNewSites: boolean("approveNewSites").notNull().default(true) +}); + +export const siteProvisioningKeyOrg = pgTable( + "siteProvisioningKeyOrg", + { + siteProvisioningKeyId: varchar("siteProvisioningKeyId", { + length: 255 + }) + .notNull() + .references(() => siteProvisioningKeys.siteProvisioningKeyId, { + onDelete: "cascade" + }), + orgId: varchar("orgId", { length: 255 }) + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) + }, + (table) => [ + primaryKey({ + columns: [table.siteProvisioningKeyId, table.orgId] + }) + ] +); + +export const eventStreamingDestinations = pgTable( + "eventStreamingDestinations", + { + destinationId: serial("destinationId").primaryKey(), + orgId: varchar("orgId", { length: 255 }) + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + sendConnectionLogs: boolean("sendConnectionLogs").notNull().default(false), + sendRequestLogs: boolean("sendRequestLogs").notNull().default(false), + sendActionLogs: boolean("sendActionLogs").notNull().default(false), + sendAccessLogs: boolean("sendAccessLogs").notNull().default(false), + type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc. + config: text("config").notNull(), // JSON string with the configuration for the destination + enabled: boolean("enabled").notNull().default(true), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() + } +); + +export const eventStreamingCursors = pgTable( + "eventStreamingCursors", + { + cursorId: serial("cursorId").primaryKey(), + destinationId: integer("destinationId") + .notNull() + .references(() => eventStreamingDestinations.destinationId, { + onDelete: "cascade" + }), + logType: varchar("logType", { length: 50 }).notNull(), // "request" | "action" | "access" | "connection" + lastSentId: bigint("lastSentId", { mode: "number" }).notNull().default(0), + lastSentAt: bigint("lastSentAt", { mode: "number" }) // epoch milliseconds, null if never sent + }, + (table) => [ + uniqueIndex("idx_eventStreamingCursors_dest_type").on( + table.destinationId, + table.logType + ) + ] +); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -357,3 +479,19 @@ export type LoginPage = InferSelectModel; export type LoginPageBranding = InferSelectModel; export type ActionAuditLog = InferSelectModel; export type AccessAuditLog = InferSelectModel; +export type ConnectionAuditLog = InferSelectModel; +export type SessionTransferToken = InferSelectModel< + typeof sessionTransferToken +>; +export type BannedEmail = InferSelectModel; +export type BannedIp = InferSelectModel; +export type SiteProvisioningKey = InferSelectModel; +export type SiteProvisioningKeyOrg = InferSelectModel< + typeof siteProvisioningKeyOrg +>; +export type EventStreamingDestination = InferSelectModel< + typeof eventStreamingDestinations +>; +export type EventStreamingCursor = InferSelectModel< + typeof eventStreamingCursors +>; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 50dc1be46..bde3e9aec 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -6,9 +6,11 @@ import { index, integer, pgTable, + primaryKey, real, serial, text, + unique, varchar } from "drizzle-orm/pg-core"; @@ -55,6 +57,9 @@ export const orgs = pgTable("orgs", { settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), + settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + .notNull() + .default(0), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) isBillingOrg: boolean("isBillingOrg"), @@ -95,7 +100,8 @@ export const sites = pgTable("sites", { publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) + dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), + status: varchar("status").$type<"pending" | "approved">().default("approved") }); export const resources = pgTable("resources", { @@ -336,9 +342,6 @@ export const userOrgs = pgTable("userOrgs", { onDelete: "cascade" }) .notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId), isOwner: boolean("isOwner").notNull().default(false), autoProvisioned: boolean("autoProvisioned").default(false), pamUsername: varchar("pamUsername") // cleaned username for ssh and such @@ -387,6 +390,22 @@ export const roles = pgTable("roles", { sshUnixGroups: text("sshUnixGroups").default("[]") }); +export const userOrgRoles = pgTable( + "userOrgRoles", + { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [unique().on(t.userId, t.orgId, t.roleId)] +); + export const roleActions = pgTable("roleActions", { roleId: integer("roleId") .notNull() @@ -454,12 +473,22 @@ export const userInvites = pgTable("userInvites", { .references(() => orgs.orgId, { onDelete: "cascade" }), email: varchar("email").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), - tokenHash: varchar("token").notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }) + tokenHash: varchar("token").notNull() }); +export const userInviteRoles = pgTable( + "userInviteRoles", + { + inviteId: varchar("inviteId") + .notNull() + .references(() => userInvites.inviteId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.inviteId, t.roleId] })] +); + export const resourcePincode = pgTable("resourcePincode", { pincodeId: serial("pincodeId").primaryKey(), resourceId: integer("resourceId") @@ -1035,7 +1064,9 @@ export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type UserInvite = InferSelectModel; +export type UserInviteRole = InferSelectModel; export type UserOrg = InferSelectModel; +export type UserOrgRole = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 280c8a119..989e111a7 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -1,4 +1,12 @@ -import { db, loginPage, LoginPage, loginPageOrg, Org, orgs, roles } from "@server/db"; +import { + db, + loginPage, + LoginPage, + loginPageOrg, + Org, + orgs, + roles +} from "@server/db"; import { Resource, ResourcePassword, @@ -12,13 +20,12 @@ import { resources, roleResources, sessions, - userOrgs, userResources, users, ResourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; export type ResourceWithAuth = { resource: Resource | null; @@ -104,24 +111,15 @@ export async function getUserSessionWithUser( } /** - * Get user organization role + * Get role name by role ID (for display). */ -export async function getUserOrgRole(userId: string, orgId: string) { - const userOrgRole = await db - .select({ - userId: userOrgs.userId, - orgId: userOrgs.orgId, - roleId: userOrgs.roleId, - isOwner: userOrgs.isOwner, - autoProvisioned: userOrgs.autoProvisioned, - roleName: roles.name - }) - .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) +export async function getRoleName(roleId: number): Promise { + const [row] = await db + .select({ name: roles.name }) + .from(roles) + .where(eq(roles.roleId, roleId)) .limit(1); - - return userOrgRole.length > 0 ? userOrgRole[0] : null; + return row?.name ?? null; } /** @@ -129,7 +127,7 @@ export async function getUserOrgRole(userId: string, orgId: string) { */ export async function getRoleResourceAccess( resourceId: number, - roleId: number + roleIds: number[] ) { const roleResourceAccess = await db .select() @@ -137,12 +135,11 @@ export async function getRoleResourceAccess( .where( and( eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) + inArray(roleResources.roleId, roleIds) ) - ) - .limit(1); + ); - return roleResourceAccess.length > 0 ? roleResourceAccess[0] : null; + return roleResourceAccess.length > 0 ? roleResourceAccess : null; } /** diff --git a/server/db/regions.ts b/server/db/regions.ts new file mode 100644 index 000000000..90a380a29 --- /dev/null +++ b/server/db/regions.ts @@ -0,0 +1,196 @@ +// Regions of the World +// as of 2025-10-25 +// +// Adapted according to the United Nations Geoscheme +// see https://www.unicode.org/cldr/charts/48/supplemental/territory_containment_un_m_49.html +// see https://unstats.un.org/unsd/methodology/m49 + +export const REGIONS = [ + { + name: "regionAfrica", + id: "002", + includes: [ + { + name: "regionNorthernAfrica", + id: "015", + countries: ["DZ", "EG", "LY", "MA", "SD", "TN", "EH"] + }, + { + name: "regionEasternAfrica", + id: "014", + countries: ["IO", "BI", "KM", "DJ", "ER", "ET", "TF", "KE", "MG", "MW", "MU", "YT", "MZ", "RE", "RW", "SC", "SO", "SS", "UG", "ZM", "ZW"] + }, + { + name: "regionMiddleAfrica", + id: "017", + countries: ["AO", "CM", "CF", "TD", "CG", "CD", "GQ", "GA", "ST"] + }, + { + name: "regionSouthernAfrica", + id: "018", + countries: ["BW", "SZ", "LS", "NA", "ZA"] + }, + { + name: "regionWesternAfrica", + id: "011", + countries: ["BJ", "BF", "CV", "CI", "GM", "GH", "GN", "GW", "LR", "ML", "MR", "NE", "NG", "SH", "SN", "SL", "TG"] + } + ] + }, + { + name: "regionAmericas", + id: "019", + includes: [ + { + name: "regionCaribbean", + id: "029", + countries: ["AI", "AG", "AW", "BS", "BB", "BQ", "VG", "KY", "CU", "CW", "DM", "DO", "GD", "GP", "HT", "JM", "MQ", "MS", "PR", "BL", "KN", "LC", "MF", "VC", "SX", "TT", "TC", "VI"] + }, + { + name: "regionCentralAmerica", + id: "013", + countries: ["BZ", "CR", "SV", "GT", "HN", "MX", "NI", "PA"] + }, + { + name: "regionSouthAmerica", + id: "005", + countries: ["AR", "BO", "BV", "BR", "CL", "CO", "EC", "FK", "GF", "GY", "PY", "PE", "GS", "SR", "UY", "VE"] + }, + { + name: "regionNorthernAmerica", + id: "021", + countries: ["BM", "CA", "GL", "PM", "US"] + } + ] + }, + { + name: "regionAsia", + id: "142", + includes: [ + { + name: "regionCentralAsia", + id: "143", + countries: ["KZ", "KG", "TJ", "TM", "UZ"] + }, + { + name: "regionEasternAsia", + id: "030", + countries: ["CN", "HK", "MO", "KP", "JP", "MN", "KR"] + }, + { + name: "regionSouthEasternAsia", + id: "035", + countries: ["BN", "KH", "ID", "LA", "MY", "MM", "PH", "SG", "TH", "TL", "VN"] + }, + { + name: "regionSouthernAsia", + id: "034", + countries: ["AF", "BD", "BT", "IN", "IR", "MV", "NP", "PK", "LK"] + }, + { + name: "regionWesternAsia", + id: "145", + countries: ["AM", "AZ", "BH", "CY", "GE", "IQ", "IL", "JO", "KW", "LB", "OM", "QA", "SA", "PS", "SY", "TR", "AE", "YE"] + } + ] + }, + { + name: "regionEurope", + id: "150", + includes: [ + { + name: "regionEasternEurope", + id: "151", + countries: ["BY", "BG", "CZ", "HU", "PL", "MD", "RO", "RU", "SK", "UA"] + }, + { + name: "regionNorthernEurope", + id: "154", + countries: ["AX", "DK", "EE", "FO", "FI", "GG", "IS", "IE", "IM", "JE", "LV", "LT", "NO", "SJ", "SE", "GB"] + }, + { + name: "regionSouthernEurope", + id: "039", + countries: ["AL", "AD", "BA", "HR", "GI", "GR", "VA", "IT", "MT", "ME", "MK", "PT", "SM", "RS", "SI", "ES"] + }, + { + name: "regionWesternEurope", + id: "155", + countries: ["AT", "BE", "FR", "DE", "LI", "LU", "MC", "NL", "CH"] + } + ] + }, + { + name: "regionOceania", + id: "009", + includes: [ + { + name: "regionAustraliaAndNewZealand", + id: "053", + countries: ["AU", "CX", "CC", "HM", "NZ", "NF"] + }, + { + name: "regionMelanesia", + id: "054", + countries: ["FJ", "NC", "PG", "SB", "VU"] + }, + { + name: "regionMicronesia", + id: "057", + countries: ["GU", "KI", "MH", "FM", "NR", "MP", "PW", "UM"] + }, + { + name: "regionPolynesia", + id: "061", + countries: ["AS", "CK", "PF", "NU", "PN", "WS", "TK", "TO", "TV", "WF"] + } + ] + } +]; + +type Subregion = { + name: string; + id: string; + countries: string[]; +}; + +type Region = { + name: string; + id: string; + includes: Subregion[]; +}; + +export function getRegionNameById(regionId: string): string | undefined { + // Check top-level regions + const region = REGIONS.find((r) => r.id === regionId); + if (region) { + return region.name; + } + + // Check subregions + for (const region of REGIONS) { + for (const subregion of region.includes) { + if (subregion.id === regionId) { + return subregion.name; + } + } + } + + return undefined; +} + +export function isValidRegionId(regionId: string): boolean { + // Check top-level regions + if (REGIONS.find((r) => r.id === regionId)) { + return true; + } + + // Check subregions + for (const region of REGIONS) { + if (region.includes.find((s) => s.id === regionId)) { + return true; + } + } + + return false; +} \ No newline at end of file diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 9cbc8d7be..832ff16f9 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -23,7 +23,8 @@ export default db; export const primaryDb = db; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] ->[0]; + >[0]; +export const DB_TYPE: "pg" | "sqlite" = "sqlite"; function checkFileExists(filePath: string): boolean { try { diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 8baeb5220..c1aa084a2 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -2,11 +2,22 @@ import { InferSelectModel } from "drizzle-orm"; import { index, integer, + primaryKey, real, sqliteTable, - text + text, + uniqueIndex } from "drizzle-orm/sqlite-core"; -import { clients, domains, exitNodes, orgs, sessions, users } from "./schema"; +import { + clients, + domains, + exitNodes, + orgs, + sessions, + siteResources, + sites, + users +} from "./schema"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -278,6 +289,7 @@ export const accessAuditLog = sqliteTable( actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), + siteResourceId: integer("siteResourceId"), ip: text("ip"), location: text("location"), type: text("type").notNull(), @@ -294,6 +306,45 @@ export const accessAuditLog = sqliteTable( ] ); +export const connectionAuditLog = sqliteTable( + "connectionAuditLog", + { + id: integer("id").primaryKey({ autoIncrement: true }), + sessionId: text("sessionId").notNull(), + siteResourceId: integer("siteResourceId").references( + () => siteResources.siteResourceId, + { onDelete: "cascade" } + ), + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }), + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + sourceAddr: text("sourceAddr").notNull(), + destAddr: text("destAddr").notNull(), + protocol: text("protocol").notNull(), + startedAt: integer("startedAt").notNull(), + endedAt: integer("endedAt"), + bytesTx: integer("bytesTx"), + bytesRx: integer("bytesRx") + }, + (table) => [ + index("idx_accessAuditLog_startedAt").on(table.startedAt), + index("idx_accessAuditLog_org_startedAt").on( + table.orgId, + table.startedAt + ), + index("idx_accessAuditLog_siteResourceId").on(table.siteResourceId) + ] +); + export const approvals = sqliteTable("approvals", { approvalId: integer("approvalId").primaryKey({ autoIncrement: true }), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds @@ -318,7 +369,6 @@ export const approvals = sqliteTable("approvals", { .notNull() }); - export const bannedEmails = sqliteTable("bannedEmails", { email: text("email").primaryKey() }); @@ -327,6 +377,84 @@ export const bannedIps = sqliteTable("bannedIps", { ip: text("ip").primaryKey() }); +export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", { + siteProvisioningKeyId: text("siteProvisioningKeyId").primaryKey(), + name: text("name").notNull(), + siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(), + lastChars: text("lastChars").notNull(), + createdAt: text("dateCreated").notNull(), + lastUsed: text("lastUsed"), + maxBatchSize: integer("maxBatchSize"), // null = no limit + numUsed: integer("numUsed").notNull().default(0), + validUntil: text("validUntil"), + approveNewSites: integer("approveNewSites", { mode: "boolean" }) + .notNull() + .default(true) +}); + +export const siteProvisioningKeyOrg = sqliteTable( + "siteProvisioningKeyOrg", + { + siteProvisioningKeyId: text("siteProvisioningKeyId") + .notNull() + .references(() => siteProvisioningKeys.siteProvisioningKeyId, { + onDelete: "cascade" + }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) + }, + (table) => [ + primaryKey({ + columns: [table.siteProvisioningKeyId, table.orgId] + }) + ] +); + +export const eventStreamingDestinations = sqliteTable( + "eventStreamingDestinations", + { + destinationId: integer("destinationId").primaryKey({ + autoIncrement: true + }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false), + sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false), + sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false), + sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }).notNull().default(false), + type: text("type").notNull(), // e.g. "http", "kafka", etc. + config: text("config").notNull(), // JSON string with the configuration for the destination + enabled: integer("enabled", { mode: "boolean" }) + .notNull() + .default(true), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() + } +); + +export const eventStreamingCursors = sqliteTable( + "eventStreamingCursors", + { + cursorId: integer("cursorId").primaryKey({ autoIncrement: true }), + destinationId: integer("destinationId") + .notNull() + .references(() => eventStreamingDestinations.destinationId, { + onDelete: "cascade" + }), + logType: text("logType").notNull(), // "request" | "action" | "access" | "connection" + lastSentId: integer("lastSentId").notNull().default(0), + lastSentAt: integer("lastSentAt") // epoch milliseconds, null if never sent + }, + (table) => [ + uniqueIndex("idx_eventStreamingCursors_dest_type").on( + table.destinationId, + table.logType + ) + ] +); + export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -348,3 +476,13 @@ export type LoginPage = InferSelectModel; export type LoginPageBranding = InferSelectModel; export type ActionAuditLog = InferSelectModel; export type AccessAuditLog = InferSelectModel; +export type ConnectionAuditLog = InferSelectModel; +export type BannedEmail = InferSelectModel; +export type BannedIp = InferSelectModel; +export type SiteProvisioningKey = InferSelectModel; +export type EventStreamingDestination = InferSelectModel< + typeof eventStreamingDestinations +>; +export type EventStreamingCursor = InferSelectModel< + typeof eventStreamingCursors +>; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 5f30d42ba..1fb04ef14 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,6 +1,13 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; -import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { + index, + integer, + primaryKey, + sqliteTable, + text, + unique +} from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -47,6 +54,9 @@ export const orgs = sqliteTable("orgs", { settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), + settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + .notNull() + .default(0), sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) isBillingOrg: integer("isBillingOrg", { mode: "boolean" }), @@ -100,7 +110,8 @@ export const sites = sqliteTable("sites", { listenPort: integer("listenPort"), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() - .default(true) + .default(true), + status: text("status").$type<"pending" | "approved">().default("approved") }); export const resources = sqliteTable("resources", { @@ -644,9 +655,6 @@ export const userOrgs = sqliteTable("userOrgs", { onDelete: "cascade" }) .notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId), isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), autoProvisioned: integer("autoProvisioned", { mode: "boolean" @@ -701,6 +709,22 @@ export const roles = sqliteTable("roles", { sshUnixGroups: text("sshUnixGroups").default("[]") }); +export const userOrgRoles = sqliteTable( + "userOrgRoles", + { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [unique().on(t.userId, t.orgId, t.roleId)] +); + export const roleActions = sqliteTable("roleActions", { roleId: integer("roleId") .notNull() @@ -786,12 +810,22 @@ export const userInvites = sqliteTable("userInvites", { .references(() => orgs.orgId, { onDelete: "cascade" }), email: text("email").notNull(), expiresAt: integer("expiresAt").notNull(), - tokenHash: text("token").notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }) + tokenHash: text("token").notNull() }); +export const userInviteRoles = sqliteTable( + "userInviteRoles", + { + inviteId: text("inviteId") + .notNull() + .references(() => userInvites.inviteId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.inviteId, t.roleId] })] +); + export const resourcePincode = sqliteTable("resourcePincode", { pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true @@ -1134,7 +1168,9 @@ export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type UserInvite = InferSelectModel; +export type UserInviteRole = InferSelectModel; export type UserOrg = InferSelectModel; +export type UserOrgRole = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index a61daca7f..0fc44c279 100644 --- a/server/index.ts +++ b/server/index.ts @@ -74,7 +74,7 @@ declare global { session: Session; userOrg?: UserOrg; apiKeyOrg?: ApiKeyOrg; - userOrgRoleId?: number; + userOrgRoleIds?: number[]; userOrgId?: string; userOrgIds?: string[]; remoteExitNode?: RemoteExitNode; diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index c08bcea71..c76dcd95b 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -8,6 +8,7 @@ export enum TierFeature { LogExport = "logExport", AccessLogs = "accessLogs", // set the retention period to none on downgrade ActionLogs = "actionLogs", // set the retention period to none on downgrade + ConnectionLogs = "connectionLogs", RotateCredentials = "rotateCredentials", MaintencePage = "maintencePage", // handle downgrade DevicePosture = "devicePosture", @@ -15,7 +16,10 @@ export enum TierFeature { SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning - SshPam = "sshPam" + SshPam = "sshPam", + FullRbac = "fullRbac", + SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed + SIEM = "siem" // handle downgrade by disabling SIEM integrations } export const tierMatrix: Record = { @@ -26,6 +30,7 @@ export const tierMatrix: Record = { [TierFeature.LogExport]: ["tier3", "enterprise"], [TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"], + [TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"], @@ -48,5 +53,8 @@ export const tierMatrix: Record = { "enterprise" ], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], - [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] + [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], + [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"], + [TierFeature.SIEM]: ["enterprise"] }; diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 2696b68c8..e16da2ea5 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -31,6 +31,7 @@ import { pickPort } from "@server/routers/target/helpers"; import { resourcePassword } from "@server/db"; import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; +import { isValidRegionId } from "@server/db/regions"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "../billing/tierMatrix"; @@ -863,6 +864,10 @@ function validateRule(rule: any) { if (!isValidUrlGlobPattern(rule.value)) { throw new Error(`Invalid URL glob pattern: ${rule.value}`); } + } else if (rule.match === "region") { + if (!isValidRegionId(rule.value)) { + throw new Error(`Invalid region ID provided: ${rule.value}`); + } } } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 2239e4f9a..6ebc509b8 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { portRangeStringSchema } from "@server/lib/ip"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; +import { isValidRegionId } from "@server/db/regions"; export const SiteSchema = z.object({ name: z.string().min(1).max(100), @@ -77,7 +78,7 @@ export const AuthSchema = z.object({ export const RuleSchema = z .object({ action: z.enum(["allow", "deny", "pass"]), - match: z.enum(["cidr", "path", "ip", "country", "asn"]), + match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]), value: z.string(), priority: z.int().optional() }) @@ -137,6 +138,19 @@ export const RuleSchema = z message: "Value must be 'AS' format or 'ALL' when match is 'asn'" } + ) + .refine( + (rule) => { + if (rule.match === "region") { + return isValidRegionId(rule.value); + } + return true; + }, + { + path: ["value"], + message: + "Value must be a valid UN M.49 region or subregion ID when match is 'region'" + } ); export const HeaderSchema = z.object({ diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 4be76dddc..02ac0c417 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -10,6 +10,7 @@ import { roles, Transaction, userClients, + userOrgRoles, userOrgs } from "@server/db"; import { getUniqueClientName } from "@server/db/names"; @@ -39,20 +40,36 @@ export async function calculateUserClientsForOrgs( return; } - // Get all user orgs - const allUserOrgs = await transaction + // Get all user orgs with all roles (for org list and role-based logic) + const userOrgRoleRows = await transaction .select() .from(userOrgs) - .innerJoin(roles, eq(roles.roleId, userOrgs.roleId)) + .innerJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where(eq(userOrgs.userId, userId)); - const userOrgIds = allUserOrgs.map(({ userOrgs: uo }) => uo.orgId); + const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))]; + const orgIdToRoleRows = new Map< + string, + (typeof userOrgRoleRows)[0][] + >(); + for (const r of userOrgRoleRows) { + const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? []; + list.push(r); + orgIdToRoleRows.set(r.userOrgs.orgId, list); + } // For each OLM, ensure there's a client in each org the user is in for (const olm of userOlms) { - for (const userRoleOrg of allUserOrgs) { - const { userOrgs: userOrg, roles: role } = userRoleOrg; - const orgId = userOrg.orgId; + for (const orgId of orgIdToRoleRows.keys()) { + const roleRowsForOrg = orgIdToRoleRows.get(orgId)!; + const userOrg = roleRowsForOrg[0].userOrgs; const [org] = await transaction .select() @@ -196,7 +213,7 @@ export async function calculateUserClientsForOrgs( const requireApproval = build !== "oss" && isOrgLicensed && - role.requireDeviceApproval; + roleRowsForOrg.some((r) => r.roles.requireDeviceApproval); const newClientData: InferInsertModel = { userId, diff --git a/server/lib/cleanupLogs.ts b/server/lib/cleanupLogs.ts index 8eb4ca77f..f5b6d8b2f 100644 --- a/server/lib/cleanupLogs.ts +++ b/server/lib/cleanupLogs.ts @@ -2,6 +2,7 @@ import { db, orgs } from "@server/db"; import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAudit"; import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit"; import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit"; +import { cleanUpOldLogs as cleanUpOldConnectionLogs } from "#dynamic/routers/newt"; import { gt, or } from "drizzle-orm"; import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils"; import { build } from "@server/build"; @@ -20,14 +21,17 @@ export function initLogCleanupInterval() { settingsLogRetentionDaysAccess: orgs.settingsLogRetentionDaysAccess, settingsLogRetentionDaysRequest: - orgs.settingsLogRetentionDaysRequest + orgs.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysConnection: + orgs.settingsLogRetentionDaysConnection }) .from(orgs) .where( or( gt(orgs.settingsLogRetentionDaysAction, 0), gt(orgs.settingsLogRetentionDaysAccess, 0), - gt(orgs.settingsLogRetentionDaysRequest, 0) + gt(orgs.settingsLogRetentionDaysRequest, 0), + gt(orgs.settingsLogRetentionDaysConnection, 0) ) ); @@ -37,7 +41,8 @@ export function initLogCleanupInterval() { orgId, settingsLogRetentionDaysAction, settingsLogRetentionDaysAccess, - settingsLogRetentionDaysRequest + settingsLogRetentionDaysRequest, + settingsLogRetentionDaysConnection } = org; if (settingsLogRetentionDaysAction > 0) { @@ -60,6 +65,13 @@ export function initLogCleanupInterval() { settingsLogRetentionDaysRequest ); } + + if (settingsLogRetentionDaysConnection > 0) { + await cleanUpOldConnectionLogs( + orgId, + settingsLogRetentionDaysConnection + ); + } } await cleanUpOldFingerprintSnapshots(365); diff --git a/server/lib/consts.ts b/server/lib/consts.ts index d53bd70bb..8ad4f48e9 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.16.0"; +export const APP_VERSION = "1.17.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 21ec78c1b..7f829bcef 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -571,6 +571,133 @@ export function generateSubnetProxyTargets( 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"; + }[]; + resourceId?: number; +}; + +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, + resourceId: siteResource.siteResourceId, + }; + } + + if (siteResource.alias && siteResource.aliasAddress) { + // also push a match for the alias address + target = { + sourcePrefixes: [], + destPrefix: `${siteResource.aliasAddress}/32`, + rewriteTo: destination, + portRange, + disableIcmp, + resourceId: siteResource.siteResourceId, + }; + } + } else if (siteResource.mode == "cidr") { + target = { + sourcePrefixes: [], + destPrefix: siteResource.destination, + portRange, + disableIcmp, + resourceId: siteResource.siteResourceId, + }; + } + + 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 // Format: "80,443,8000-9000" or "*" for all ports, or empty string export const portRangeStringSchema = z diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index cca0aa6aa..c3e796fc1 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -79,6 +79,7 @@ export const configSchema = z .default(3001) .transform(stoi) .pipe(portSchema), + badger_override: z.string().optional(), next_port: portSchema .optional() .default(3002) @@ -302,8 +303,8 @@ export const configSchema = z .optional() .default({ block_size: 24, - subnet_group: "100.90.128.0/24", - utility_subnet_group: "100.96.128.0/24" + subnet_group: "100.90.128.0/20", + utility_subnet_group: "100.96.128.0/20" }), rate_limits: z .object({ diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 121e2c7f0..8459ce249 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -14,6 +14,7 @@ import { siteResources, sites, Transaction, + userOrgRoles, userOrgs, userSiteResources } from "@server/db"; @@ -32,7 +33,7 @@ import logger from "@server/logger"; import { generateAliasConfig, generateRemoteSubnets, - generateSubnetProxyTargets, + generateSubnetProxyTargetV2, parseEndpoint, formatEndpoint } from "@server/lib/ip"; @@ -77,10 +78,10 @@ export async function getClientSiteResourceAccess( // get all of the users in these roles const userIdsFromRoles = await trx .select({ - userId: userOrgs.userId + userId: userOrgRoles.userId }) - .from(userOrgs) - .where(inArray(userOrgs.roleId, roleIds)) + .from(userOrgRoles) + .where(inArray(userOrgRoles.roleId, roleIds)) .then((rows) => rows.map((row) => row.userId)); const newAllUserIds = Array.from( @@ -660,19 +661,16 @@ async function handleSubnetProxyTargetUpdates( ); if (addedClients.length > 0) { - const targetsToAdd = generateSubnetProxyTargets( + const targetToAdd = generateSubnetProxyTargetV2( siteResource, addedClients ); - if (targetsToAdd.length > 0) { - logger.info( - `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` - ); + if (targetToAdd) { proxyJobs.push( addSubnetProxyTargets( newt.newtId, - targetsToAdd, + [targetToAdd], newt.version ) ); @@ -700,19 +698,16 @@ async function handleSubnetProxyTargetUpdates( ); if (removedClients.length > 0) { - const targetsToRemove = generateSubnetProxyTargets( + const targetToRemove = generateSubnetProxyTargetV2( siteResource, removedClients ); - if (targetsToRemove.length > 0) { - logger.info( - `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` - ); + if (targetToRemove) { proxyJobs.push( removeSubnetProxyTargets( newt.newtId, - targetsToRemove, + [targetToRemove], newt.version ) ); @@ -820,12 +815,12 @@ export async function rebuildClientAssociationsFromClient( // Role-based access const roleIds = await trx - .select({ roleId: userOrgs.roleId }) - .from(userOrgs) + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) .where( and( - eq(userOrgs.userId, client.userId), - eq(userOrgs.orgId, client.orgId) + eq(userOrgRoles.userId, client.userId), + eq(userOrgRoles.orgId, client.orgId) ) ) // this needs to be locked onto this org or else cross-org access could happen .then((rows) => rows.map((row) => row.roleId)); @@ -1169,7 +1164,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const targets = generateSubnetProxyTargets(resource, [ + const target = generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, @@ -1177,11 +1172,11 @@ async function handleMessagesForClientResources( } ]); - if (targets.length > 0) { + if (target) { proxyJobs.push( addSubnetProxyTargets( newt.newtId, - targets, + [target], newt.version ) ); @@ -1246,7 +1241,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const targets = generateSubnetProxyTargets(resource, [ + const target = generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, @@ -1254,11 +1249,11 @@ async function handleMessagesForClientResources( } ]); - if (targets.length > 0) { + if (target) { proxyJobs.push( removeSubnetProxyTargets( newt.newtId, - targets, + [target], newt.version ) ); diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts index 6ed10039b..809266b73 100644 --- a/server/lib/userOrg.ts +++ b/server/lib/userOrg.ts @@ -6,7 +6,7 @@ import { siteResources, sites, Transaction, - UserOrg, + userOrgRoles, userOrgs, userResources, userSiteResources, @@ -19,9 +19,22 @@ import { FeatureId } from "@server/lib/billing"; export async function assignUserToOrg( org: Org, values: typeof userOrgs.$inferInsert, + roleIds: number[], trx: Transaction | typeof db = db ) { + const uniqueRoleIds = [...new Set(roleIds)]; + if (uniqueRoleIds.length === 0) { + throw new Error("assignUserToOrg requires at least one roleId"); + } + const [userOrg] = await trx.insert(userOrgs).values(values).returning(); + await trx.insert(userOrgRoles).values( + uniqueRoleIds.map((roleId) => ({ + userId: userOrg.userId, + orgId: userOrg.orgId, + roleId + })) + ); // calculate if the user is in any other of the orgs before we count it as an add to the billing org if (org.billingOrgId) { @@ -58,6 +71,14 @@ export async function removeUserFromOrg( userId: string, trx: Transaction | typeof db = db ) { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, org.orgId) + ) + ); await trx .delete(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId))); diff --git a/server/lib/userOrgRoles.ts b/server/lib/userOrgRoles.ts new file mode 100644 index 000000000..c3db64af3 --- /dev/null +++ b/server/lib/userOrgRoles.ts @@ -0,0 +1,36 @@ +import { db, roles, userOrgRoles } from "@server/db"; +import { and, eq } from "drizzle-orm"; + +/** + * Get all role IDs a user has in an organization. + * Returns empty array if the user has no roles in the org (callers must treat as no access). + */ +export async function getUserOrgRoleIds( + userId: string, + orgId: string +): Promise { + const rows = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + return rows.map((r) => r.roleId); +} + +export async function getUserOrgRoles( + userId: string, + orgId: string +): Promise<{ roleId: number; roleName: string }[]> { + const rows = await db + .select({ roleId: userOrgRoles.roleId, roleName: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) + ); + return rows; +} diff --git a/server/middlewares/getUserOrgs.ts b/server/middlewares/getUserOrgs.ts index d7905700e..fa9794fb9 100644 --- a/server/middlewares/getUserOrgs.ts +++ b/server/middlewares/getUserOrgs.ts @@ -21,8 +21,7 @@ export async function getUserOrgs( try { const userOrganizations = await db .select({ - orgId: userOrgs.orgId, - roleId: userOrgs.roleId + orgId: userOrgs.orgId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 6437c90e2..48025e8e7 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -17,6 +17,7 @@ export * from "./verifyAccessTokenAccess"; export * from "./requestTimeout"; export * from "./verifyClientAccess"; export * from "./verifyUserHasAction"; +export * from "./verifyUserCanSetUserOrgRoles"; export * from "./verifyUserIsServerAdmin"; export * from "./verifyIsLoggedInUser"; export * from "./verifyIsLoggedInUser"; @@ -24,6 +25,7 @@ export * from "./verifyClientAccess"; export * from "./integration"; export * from "./verifyUserHasAction"; export * from "./verifyApiKeyAccess"; +export * from "./verifySiteProvisioningKeyAccess"; export * from "./verifyDomainAccess"; export * from "./verifyUserIsOrgOwner"; export * from "./verifySiteResourceAccess"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index df186c1c8..8a213c6d2 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -1,6 +1,7 @@ export * from "./verifyApiKey"; export * from "./verifyApiKeyOrgAccess"; export * from "./verifyApiKeyHasAction"; +export * from "./verifyApiKeyCanSetUserOrgRoles"; export * from "./verifyApiKeySiteAccess"; export * from "./verifyApiKeyResourceAccess"; export * from "./verifyApiKeyTargetAccess"; diff --git a/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts b/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts new file mode 100644 index 000000000..894665095 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts @@ -0,0 +1,74 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { ActionsEnum } from "@server/auth/actions"; +import { db } from "@server/db"; +import { apiKeyActions } from "@server/db"; +import { and, eq } from "drizzle-orm"; + +async function apiKeyHasAction(apiKeyId: string, actionId: ActionsEnum) { + const [row] = await db + .select() + .from(apiKeyActions) + .where( + and( + eq(apiKeyActions.apiKeyId, apiKeyId), + eq(apiKeyActions.actionId, actionId) + ) + ); + return !!row; +} + +/** + * Allows setUserOrgRoles on the key, or both addUserRole and removeUserRole. + */ +export function verifyApiKeyCanSetUserOrgRoles() { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + if (!req.apiKey) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "API Key not authenticated" + ) + ); + } + + const keyId = req.apiKey.apiKeyId; + + if (await apiKeyHasAction(keyId, ActionsEnum.setUserOrgRoles)) { + return next(); + } + + const hasAdd = await apiKeyHasAction(keyId, ActionsEnum.addUserRole); + const hasRemove = await apiKeyHasAction( + keyId, + ActionsEnum.removeUserRole + ); + + if (hasAdd && hasRemove) { + return next(); + } + + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have permission perform this action" + ) + ); + } catch (error) { + logger.error("Error verifying API key set user org roles:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key action access" + ) + ); + } + }; +} diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 033b326d9..f1f2ca52e 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "@server/auth/canUserAccessResource"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyAccessTokenAccess( req: Request, @@ -93,7 +94,10 @@ export async function verifyAccessTokenAccess( ) ); } else { - req.userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource[0].orgId! + ); req.userOrgId = resource[0].orgId!; } @@ -118,7 +122,7 @@ export async function verifyAccessTokenAccess( const resourceAllowed = await canUserAccessResource({ userId, resourceId, - roleId: req.userOrgRoleId! + roleIds: req.userOrgRoleIds ?? [] }); if (!resourceAllowed) { diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 253bfc2dd..0dbeac2cb 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { roles, userOrgs } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyAdmin( req: Request, @@ -62,13 +63,29 @@ export async function verifyAdmin( } } - const userRole = await db + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId!); + + if (req.userOrgRoleIds.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have Admin access" + ) + ); + } + + const userAdminRoles = await db .select() .from(roles) - .where(eq(roles.roleId, req.userOrg.roleId)) + .where( + and( + inArray(roles.roleId, req.userOrgRoleIds), + eq(roles.isAdmin, true) + ) + ) .limit(1); - if (userRole.length === 0 || !userRole[0].isAdmin) { + if (userAdminRoles.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts index 6edc5ab8e..b497892c8 100644 --- a/server/middlewares/verifyApiKeyAccess.ts +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs, apiKeys, apiKeyOrg } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyApiKeyAccess( req: Request, @@ -103,8 +104,10 @@ export async function verifyApiKeyAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + orgId + ); return next(); } catch (error) { diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts index d2df38a4b..1d994b53f 100644 --- a/server/middlewares/verifyClientAccess.ts +++ b/server/middlewares/verifyClientAccess.ts @@ -1,11 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { Client, db } from "@server/db"; import { userOrgs, clients, roleClients, userClients } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import logger from "@server/logger"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyClientAccess( req: Request, @@ -113,21 +114,30 @@ export async function verifyClientAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + client.orgId + ); req.userOrgId = client.orgId; - // Check role-based site access first - const [roleClientAccess] = await db - .select() - .from(roleClients) - .where( - and( - eq(roleClients.clientId, client.clientId), - eq(roleClients.roleId, userOrgRoleId) - ) - ) - .limit(1); + // Check role-based client access (any of user's roles) + const roleClientAccessList = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleClients) + .where( + and( + eq(roleClients.clientId, client.clientId), + inArray( + roleClients.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; + const [roleClientAccess] = roleClientAccessList; if (roleClientAccess) { // User has access to the site through their role diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts index 88ffe678d..c9ecf42e0 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db, domains, orgDomains } from "@server/db"; -import { userOrgs, apiKeyOrg } from "@server/db"; +import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyDomainAccess( req: Request, @@ -63,7 +64,7 @@ export async function verifyDomainAccess( .where( and( eq(userOrgs.userId, userId), - eq(userOrgs.orgId, apiKeyOrg.orgId) + eq(userOrgs.orgId, orgId) ) ) .limit(1); @@ -97,8 +98,7 @@ export async function verifyDomainAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); return next(); } catch (error) { diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 729766abd..cb797afb0 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; -import { db, orgs } from "@server/db"; +import { db } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyOrgAccess( req: Request, @@ -64,8 +65,8 @@ export async function verifyOrgAccess( } } - // User has access, attach the user's role to the request for potential future use - req.userOrgRoleId = req.userOrg.roleId; + // User has access, attach the user's role(s) to the request for potential future use + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); req.userOrgId = orgId; return next(); diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index 2ae591ee1..ba49f02e3 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db, Resource } from "@server/db"; import { resources, userOrgs, userResources, roleResources } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyResourceAccess( req: Request, @@ -107,20 +108,28 @@ export async function verifyResourceAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource.orgId + ); req.userOrgId = resource.orgId; - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resource.resourceId), - eq(roleResources.roleId, userOrgRoleId) - ) - ) - .limit(1); + const roleResourceAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resource.resourceId), + inArray( + roleResources.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return next(); diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index 8858ab53f..380b82048 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyRoleAccess( req: Request, @@ -99,7 +100,6 @@ export async function verifyRoleAccess( } if (!req.userOrg) { - // get the userORg const userOrg = await db .select() .from(userOrgs) @@ -109,7 +109,7 @@ export async function verifyRoleAccess( .limit(1); req.userOrg = userOrg[0]; - req.userOrgRoleId = userOrg[0].roleId; + req.userOrgRoleIds = await getUserOrgRoleIds(userId, orgId!); } if (!req.userOrg) { diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index 98858cfb9..e630cf0f1 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { sites, Site, userOrgs, userSites, roleSites, roles } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq, inArray, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifySiteAccess( req: Request, @@ -112,21 +113,29 @@ export async function verifySiteAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + site.orgId + ); req.userOrgId = site.orgId; - // Check role-based site access first - const roleSiteAccess = await db - .select() - .from(roleSites) - .where( - and( - eq(roleSites.siteId, site.siteId), - eq(roleSites.roleId, userOrgRoleId) - ) - ) - .limit(1); + // Check role-based site access first (any of user's roles) + const roleSiteAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleSites) + .where( + and( + eq(roleSites.siteId, site.siteId), + inArray( + roleSites.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleSiteAccess.length > 0) { // User's role has access to the site diff --git a/server/middlewares/verifySiteProvisioningKeyAccess.ts b/server/middlewares/verifySiteProvisioningKeyAccess.ts new file mode 100644 index 000000000..bdf12c821 --- /dev/null +++ b/server/middlewares/verifySiteProvisioningKeyAccess.ts @@ -0,0 +1,135 @@ +import { Request, Response, NextFunction } from "express"; +import { db, userOrgs, siteProvisioningKeys, siteProvisioningKeyOrg } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; + +export async function verifySiteProvisioningKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const siteProvisioningKeyId = req.params.siteProvisioningKeyId; + const orgId = req.params.orgId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!siteProvisioningKeyId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + const [row] = await db + .select() + .from(siteProvisioningKeys) + .innerJoin( + siteProvisioningKeyOrg, + and( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .limit(1); + + if (!row?.siteProvisioningKeys) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site provisioning key with ID ${siteProvisioningKeyId} not found` + ) + ); + } + + if (!row.siteProvisioningKeyOrg.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Site provisioning key with ID ${siteProvisioningKeyId} does not have an organization ID` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq( + userOrgs.orgId, + row.siteProvisioningKeyOrg.orgId + ) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + row.siteProvisioningKeyOrg.orgId + ); + req.userOrgId = row.siteProvisioningKeyOrg.orgId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site provisioning key access" + ) + ); + } +} diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index ca7d37fb3..8d5bd656f 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -1,11 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db"; import { siteResources } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifySiteResourceAccess( req: Request, @@ -109,23 +110,34 @@ export async function verifySiteResourceAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + siteResource.orgId + ); req.userOrgId = siteResource.orgId; // Attach the siteResource to the request for use in the next middleware/route req.siteResource = siteResource; - const roleResourceAccess = await db - .select() - .from(roleSiteResources) - .where( - and( - eq(roleSiteResources.siteResourceId, siteResourceIdNum), - eq(roleSiteResources.roleId, userOrgRoleId) - ) - ) - .limit(1); + const roleResourceAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleSiteResources) + .where( + and( + eq( + roleSiteResources.siteResourceId, + siteResourceIdNum + ), + inArray( + roleSiteResources.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return next(); diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 7e433fcb8..141a04549 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "../auth/canUserAccessResource"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyTargetAccess( req: Request, @@ -99,7 +100,10 @@ export async function verifyTargetAccess( ) ); } else { - req.userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource[0].orgId! + ); req.userOrgId = resource[0].orgId!; } @@ -126,7 +130,7 @@ export async function verifyTargetAccess( const resourceAllowed = await canUserAccessResource({ userId, resourceId, - roleId: req.userOrgRoleId! + roleIds: req.userOrgRoleIds ?? [] }); if (!resourceAllowed) { diff --git a/server/middlewares/verifyUserCanSetUserOrgRoles.ts b/server/middlewares/verifyUserCanSetUserOrgRoles.ts new file mode 100644 index 000000000..1a7554ab3 --- /dev/null +++ b/server/middlewares/verifyUserCanSetUserOrgRoles.ts @@ -0,0 +1,54 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; + +/** + * Allows the new setUserOrgRoles action, or legacy permission pair addUserRole + removeUserRole. + */ +export function verifyUserCanSetUserOrgRoles() { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const canSet = await checkUserActionPermission( + ActionsEnum.setUserOrgRoles, + req + ); + if (canSet) { + return next(); + } + + const canAdd = await checkUserActionPermission( + ActionsEnum.addUserRole, + req + ); + const canRemove = await checkUserActionPermission( + ActionsEnum.removeUserRole, + req + ); + + if (canAdd && canRemove) { + return next(); + } + + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } catch (error) { + logger.error("Error verifying set user org roles access:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying role access" + ) + ); + } + }; +} diff --git a/server/middlewares/verifyUserInRole.ts b/server/middlewares/verifyUserInRole.ts index 2a153114d..18eeb44f3 100644 --- a/server/middlewares/verifyUserInRole.ts +++ b/server/middlewares/verifyUserInRole.ts @@ -12,7 +12,7 @@ export async function verifyUserInRole( const roleId = parseInt( req.params.roleId || req.body.roleId || req.query.roleId ); - const userRoleId = req.userOrgRoleId; + const userOrgRoleIds = req.userOrgRoleIds ?? []; if (isNaN(roleId)) { return next( @@ -20,7 +20,7 @@ export async function verifyUserInRole( ); } - if (!userRoleId) { + if (userOrgRoleIds.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -29,7 +29,7 @@ export async function verifyUserInRole( ); } - if (userRoleId !== roleId) { + if (!userOrgRoleIds.includes(roleId)) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/private/cleanup.ts b/server/private/cleanup.ts index 5321fbc9e..af4238721 100644 --- a/server/private/cleanup.ts +++ b/server/private/cleanup.ts @@ -12,17 +12,21 @@ */ import { rateLimitService } from "#private/lib/rateLimit"; +import { logStreamingManager } from "#private/lib/logStreaming"; import { cleanup as wsCleanup } from "#private/routers/ws"; import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage"; +import { flushConnectionLogToDb } from "#private/routers/newt"; import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"; import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator"; async function cleanup() { await stopPingAccumulator(); await flushBandwidthToDb(); + await flushConnectionLogToDb(); await flushSiteBandwidthToDb(); await rateLimitService.cleanup(); await wsCleanup(); + await logStreamingManager.shutdown(); process.exit(0); } @@ -31,4 +35,4 @@ export async function initCleanup() { // Handle process termination process.on("SIGTERM", () => cleanup()); process.on("SIGINT", () => cleanup()); -} \ No newline at end of file +} diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts index 91db548f7..e56490795 100644 --- a/server/private/lib/logAccessAudit.ts +++ b/server/private/lib/logAccessAudit.ts @@ -74,6 +74,7 @@ export async function logAccessAudit(data: { type: string; orgId: string; resourceId?: number; + siteResourceId?: number; user?: { username: string; userId: string }; apiKey?: { name: string | null; apiKeyId: string }; metadata?: any; @@ -134,6 +135,7 @@ export async function logAccessAudit(data: { type: data.type, metadata, resourceId: data.resourceId, + siteResourceId: data.siteResourceId, userAgent: data.userAgent, ip: clientIp, location: countryCode diff --git a/server/private/lib/logConnectionAudit.ts b/server/private/lib/logConnectionAudit.ts new file mode 100644 index 000000000..c7e786280 --- /dev/null +++ b/server/private/lib/logConnectionAudit.ts @@ -0,0 +1,234 @@ +/* + * 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 { logsDb, connectionAuditLog } from "@server/db"; +import logger from "@server/logger"; +import { and, eq, lt } from "drizzle-orm"; +import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; + +// --------------------------------------------------------------------------- +// Retry configuration for deadlock handling +// --------------------------------------------------------------------------- + +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 50; + +// --------------------------------------------------------------------------- +// Buffer / flush configuration +// --------------------------------------------------------------------------- + +/** How often to flush accumulated connection log data to the database. */ +const FLUSH_INTERVAL_MS = 30_000; // 30 seconds + +/** Maximum number of records to buffer before forcing a flush. */ +const MAX_BUFFERED_RECORDS = 500; + +/** Maximum number of records to insert in a single database batch. */ +const INSERT_BATCH_SIZE = 100; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ConnectionLogRecord { + sessionId: string; + siteResourceId: number; + orgId: string; + siteId: number; + clientId: number | null; + userId: string | null; + sourceAddr: string; + destAddr: string; + protocol: string; + startedAt: number; // epoch seconds + endedAt: number | null; + bytesTx: number | null; + bytesRx: number | null; +} + +// --------------------------------------------------------------------------- +// In-memory buffer +// --------------------------------------------------------------------------- + +let buffer: ConnectionLogRecord[] = []; + +// --------------------------------------------------------------------------- +// Deadlock helpers +// --------------------------------------------------------------------------- + +function isDeadlockError(error: any): boolean { + return ( + error?.code === "40P01" || + error?.cause?.code === "40P01" || + (error?.message && error.message.includes("deadlock")) + ); +} + +async function withDeadlockRetry( + operation: () => Promise, + context: string +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error: any) { + if (isDeadlockError(error) && attempt < MAX_RETRIES) { + attempt++; + const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS; + const jitter = Math.random() * baseDelay; + const delay = baseDelay + jitter; + logger.warn( + `Deadlock detected in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw error; + } + } +} + +// --------------------------------------------------------------------------- +// Flush +// --------------------------------------------------------------------------- + +/** + * Flush all buffered connection log records to the database. + * + * Swaps out the buffer before writing so that any records added during the + * flush are captured in the new buffer rather than being lost. Entries that + * fail to write are re-queued back into the buffer so they will be retried + * on the next flush. + * + * This function is exported so that the application's graceful-shutdown + * cleanup handler can call it before the process exits. + */ +export async function flushConnectionLogToDb(): Promise { + if (buffer.length === 0) { + return; + } + + // Atomically swap out the buffer so new data keeps flowing in + const snapshot = buffer; + buffer = []; + + logger.debug( + `Flushing ${snapshot.length} connection log record(s) to the database` + ); + + for (let i = 0; i < snapshot.length; i += INSERT_BATCH_SIZE) { + const batch = snapshot.slice(i, i + INSERT_BATCH_SIZE); + + try { + await withDeadlockRetry(async () => { + await logsDb.insert(connectionAuditLog).values(batch); + }, `flush connection log batch (${batch.length} records)`); + } catch (error) { + logger.error( + `Failed to flush connection log batch of ${batch.length} records:`, + error + ); + + // Re-queue the failed batch so it is retried on the next flush + buffer = [...batch, ...buffer]; + + // Cap buffer to prevent unbounded growth if the DB is unreachable + const hardLimit = MAX_BUFFERED_RECORDS * 5; + if (buffer.length > hardLimit) { + const dropped = buffer.length - hardLimit; + buffer = buffer.slice(0, hardLimit); + logger.warn( + `Connection log buffer overflow, dropped ${dropped} oldest records` + ); + } + + // Stop processing further batches from this snapshot — they will + // be picked up via the re-queued records on the next flush. + const remaining = snapshot.slice(i + INSERT_BATCH_SIZE); + if (remaining.length > 0) { + buffer = [...remaining, ...buffer]; + } + break; + } + } +} + +// --------------------------------------------------------------------------- +// Periodic flush timer +// --------------------------------------------------------------------------- + +const flushTimer = setInterval(async () => { + try { + await flushConnectionLogToDb(); + } catch (error) { + logger.error( + "Unexpected error during periodic connection log flush:", + error + ); + } +}, FLUSH_INTERVAL_MS); + +// Calling unref() means this timer will not keep the Node.js event loop alive +// on its own — the process can still exit normally when there is no other work +// left. The graceful-shutdown path will call flushConnectionLogToDb() explicitly +// before process.exit(), so no data is lost. +flushTimer.unref(); + +// --------------------------------------------------------------------------- +// Cleanup +// --------------------------------------------------------------------------- + +export async function cleanUpOldLogs( + orgId: string, + retentionDays: number +): Promise { + const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); + + try { + await logsDb + .delete(connectionAuditLog) + .where( + and( + lt(connectionAuditLog.startedAt, cutoffTimestamp), + eq(connectionAuditLog.orgId, orgId) + ) + ); + } catch (error) { + logger.error("Error cleaning up old connection audit logs:", error); + } +} + +// --------------------------------------------------------------------------- +// Public logging entry-point +// --------------------------------------------------------------------------- + +/** + * Buffer a single connection log record for eventual persistence. + * + * Records are written to the database in batches either when the buffer + * reaches MAX_BUFFERED_RECORDS or when the periodic flush timer fires. + */ +export function logConnectionAudit(record: ConnectionLogRecord): void { + buffer.push(record); + + if (buffer.length >= MAX_BUFFERED_RECORDS) { + // Fire and forget — errors are handled inside flushConnectionLogToDb + flushConnectionLogToDb().catch((error) => { + logger.error( + "Unexpected error during size-triggered connection log flush:", + error + ); + }); + } +} \ No newline at end of file diff --git a/server/private/lib/logStreaming/LogStreamingManager.ts b/server/private/lib/logStreaming/LogStreamingManager.ts new file mode 100644 index 000000000..04e35ad00 --- /dev/null +++ b/server/private/lib/logStreaming/LogStreamingManager.ts @@ -0,0 +1,773 @@ +/* + * 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 { + db, + logsDb, + eventStreamingDestinations, + eventStreamingCursors, + requestAuditLog, + actionAuditLog, + accessAuditLog, + connectionAuditLog +} from "@server/db"; +import logger from "@server/logger"; +import { and, eq, gt, desc, max, sql } from "drizzle-orm"; +import { + LogType, + LOG_TYPES, + LogEvent, + DestinationFailureState, + HttpConfig +} from "./types"; +import { LogDestinationProvider } from "./providers/LogDestinationProvider"; +import { HttpLogDestination } from "./providers/HttpLogDestination"; +import type { EventStreamingDestination } from "@server/db"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/** + * How often (ms) the manager polls all destinations for new log records. + * Destinations that were behind (full batch returned) will be re-polled + * immediately without waiting for this interval. + */ +const POLL_INTERVAL_MS = 30_000; + +/** + * Maximum number of log records fetched from the DB in a single query. + * This also controls the maximum size of one HTTP POST body. + */ +const BATCH_SIZE = 250; + +/** + * Minimum delay (ms) between consecutive HTTP requests to the same destination + * during a catch-up run. Prevents bursting thousands of requests back-to-back + * when a destination has fallen behind. + */ +const INTER_BATCH_DELAY_MS = 100; + +/** + * Maximum number of consecutive back-to-back batches to process for a single + * destination per poll cycle. After this limit the destination will wait for + * the next scheduled poll before continuing, giving other destinations a turn. + */ +const MAX_CATCHUP_BATCHES = 20; + +/** + * Back-off schedule (ms) indexed by consecutive failure count. + * After the last entry the max value is re-used. + */ +const BACKOFF_SCHEDULE_MS = [ + 60_000, // 1 min (failure 1) + 2 * 60_000, // 2 min (failure 2) + 5 * 60_000, // 5 min (failure 3) + 10 * 60_000, // 10 min (failure 4) + 30 * 60_000 // 30 min (failure 5+) +]; + +/** + * If a destination has been continuously unreachable for this long, its + * cursors are advanced to the current max row id and the backlog is silently + * discarded. This prevents unbounded queue growth when a webhook endpoint is + * down for an extended period. A prominent warning is logged so operators are + * aware logs were dropped. + * + * Default: 24 hours. + */ +const MAX_BACKLOG_DURATION_MS = 24 * 60 * 60_000; + +// --------------------------------------------------------------------------- +// LogStreamingManager +// --------------------------------------------------------------------------- + +/** + * Orchestrates periodic polling of the four audit-log tables and forwards new + * records to every enabled event-streaming destination. + * + * ### Design + * - **Interval-based**: a timer fires every `POLL_INTERVAL_MS`. On each tick + * every enabled destination is processed in sequence. + * - **Cursor-based**: the last successfully forwarded row `id` is persisted in + * the `eventStreamingCursors` table so state survives restarts. + * - **Catch-up**: if a full batch is returned the destination is immediately + * re-queried (up to `MAX_CATCHUP_BATCHES` times) before yielding. + * - **Smoothing**: `INTER_BATCH_DELAY_MS` is inserted between consecutive + * catch-up batches to avoid hammering the remote endpoint. + * - **Back-off**: consecutive send failures trigger exponential back-off + * (tracked in-memory per destination). Successful sends reset the counter. + * - **Backlog abandonment**: if a destination remains unreachable for longer + * than `MAX_BACKLOG_DURATION_MS`, all cursors for that destination are + * advanced to the current max id so the backlog is discarded and streaming + * resumes from the present moment on recovery. + */ +export class LogStreamingManager { + private pollTimer: ReturnType | null = null; + private isRunning = false; + private isPolling = false; + + /** In-memory back-off state keyed by destinationId. */ + private readonly failures = new Map(); + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + start(): void { + if (this.isRunning) return; + this.isRunning = true; + logger.info("LogStreamingManager: started"); + this.schedulePoll(POLL_INTERVAL_MS); + } + + // ------------------------------------------------------------------------- + // Cursor initialisation (call this when a destination is first created) + // ------------------------------------------------------------------------- + + /** + * Eagerly seed cursors for every log type at the **current** max row id of + * each table, scoped to the destination's org. + * + * Call this immediately after inserting a new row into + * `eventStreamingDestinations` so the destination only receives events + * that were written *after* it was created. If a cursor row already exists + * (e.g. the method is called twice) it is left untouched. + * + * The manager also has a lazy fallback inside `getOrCreateCursor` for + * destinations that existed before this method was introduced. + */ + async initializeCursorsForDestination( + destinationId: number, + orgId: string + ): Promise { + for (const logType of LOG_TYPES) { + const currentMaxId = await this.getCurrentMaxId(logType, orgId); + try { + await db + .insert(eventStreamingCursors) + .values({ + destinationId, + logType, + lastSentId: currentMaxId, + lastSentAt: null + }) + .onConflictDoNothing(); + } catch (err) { + logger.warn( + `LogStreamingManager: could not initialise cursor for ` + + `destination ${destinationId} logType="${logType}"`, + err + ); + } + } + + logger.debug( + `LogStreamingManager: cursors initialised for destination ${destinationId} ` + + `(org=${orgId})` + ); + } + + async shutdown(): Promise { + this.isRunning = false; + if (this.pollTimer !== null) { + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + // Wait for any in-progress poll to finish before returning so that + // callers (graceful-shutdown handlers) can safely exit afterward. + const deadline = Date.now() + 15_000; + while (this.isPolling && Date.now() < deadline) { + await sleep(100); + } + logger.info("LogStreamingManager: stopped"); + } + + // ------------------------------------------------------------------------- + // Scheduling + // ------------------------------------------------------------------------- + + private schedulePoll(delayMs: number): void { + this.pollTimer = setTimeout(() => { + this.pollTimer = null; + this.runPoll() + .catch((err) => + logger.error("LogStreamingManager: unexpected poll error", err) + ) + .finally(() => { + if (this.isRunning) { + this.schedulePoll(POLL_INTERVAL_MS); + } + }); + }, delayMs); + + // Do not keep the event loop alive just for the poll timer – the + // graceful-shutdown path calls shutdown() explicitly. + this.pollTimer.unref?.(); + } + + // ------------------------------------------------------------------------- + // Poll cycle + // ------------------------------------------------------------------------- + + private async runPoll(): Promise { + if (this.isPolling) return; // previous poll still running – skip + this.isPolling = true; + + try { + const destinations = await this.loadEnabledDestinations(); + if (destinations.length === 0) return; + + for (const dest of destinations) { + if (!this.isRunning) break; + await this.processDestination(dest).catch((err) => { + // Individual destination errors must never abort the whole cycle + logger.error( + `LogStreamingManager: unhandled error for destination ${dest.destinationId}`, + err + ); + }); + } + } finally { + this.isPolling = false; + } + } + + // ------------------------------------------------------------------------- + // Per-destination processing + // ------------------------------------------------------------------------- + + private async processDestination( + dest: EventStreamingDestination + ): Promise { + const failState = this.failures.get(dest.destinationId); + + // Check whether this destination has been unreachable long enough that + // we should give up on the accumulated backlog. + if (failState) { + const failingForMs = Date.now() - failState.firstFailedAt; + if (failingForMs >= MAX_BACKLOG_DURATION_MS) { + await this.abandonBacklog(dest, failState); + this.failures.delete(dest.destinationId); + // Cursors now point to the current head – retry on next poll. + return; + } + } + + // Check regular exponential back-off window + if (failState && Date.now() < failState.nextRetryAt) { + logger.debug( + `LogStreamingManager: destination ${dest.destinationId} in back-off, skipping` + ); + return; + } + + // Parse config – skip destination if config is unparseable + let config: HttpConfig; + try { + config = JSON.parse(dest.config) as HttpConfig; + } catch (err) { + logger.error( + `LogStreamingManager: destination ${dest.destinationId} has invalid JSON config`, + err + ); + return; + } + + const provider = this.createProvider(dest.type, config); + if (!provider) { + logger.warn( + `LogStreamingManager: unsupported destination type "${dest.type}" ` + + `for destination ${dest.destinationId} – skipping` + ); + return; + } + + const enabledTypes: LogType[] = []; + if (dest.sendRequestLogs) enabledTypes.push("request"); + if (dest.sendActionLogs) enabledTypes.push("action"); + if (dest.sendAccessLogs) enabledTypes.push("access"); + if (dest.sendConnectionLogs) enabledTypes.push("connection"); + + if (enabledTypes.length === 0) return; + + let anyFailure = false; + + for (const logType of enabledTypes) { + if (!this.isRunning) break; + try { + await this.processLogType(dest, provider, logType); + } catch (err) { + anyFailure = true; + logger.error( + `LogStreamingManager: failed to process "${logType}" logs ` + + `for destination ${dest.destinationId}`, + err + ); + } + } + + if (anyFailure) { + this.recordFailure(dest.destinationId); + } else { + // Any success resets the failure/back-off state + if (this.failures.has(dest.destinationId)) { + this.failures.delete(dest.destinationId); + logger.info( + `LogStreamingManager: destination ${dest.destinationId} recovered` + ); + } + } + } + + /** + * Advance every cursor for the destination to the current max row id, + * effectively discarding the accumulated backlog. Called when the + * destination has been unreachable for longer than MAX_BACKLOG_DURATION_MS. + */ + private async abandonBacklog( + dest: EventStreamingDestination, + failState: DestinationFailureState + ): Promise { + const failingForHours = ( + (Date.now() - failState.firstFailedAt) / + 3_600_000 + ).toFixed(1); + + let totalDropped = 0; + + for (const logType of LOG_TYPES) { + try { + const currentMaxId = await this.getCurrentMaxId( + logType, + dest.orgId + ); + + // Find out how many rows are being skipped for this type + const cursor = await db + .select({ lastSentId: eventStreamingCursors.lastSentId }) + .from(eventStreamingCursors) + .where( + and( + eq(eventStreamingCursors.destinationId, dest.destinationId), + eq(eventStreamingCursors.logType, logType) + ) + ) + .limit(1); + + const prevId = cursor[0]?.lastSentId ?? currentMaxId; + totalDropped += Math.max(0, currentMaxId - prevId); + + await this.updateCursor( + dest.destinationId, + logType, + currentMaxId + ); + } catch (err) { + logger.error( + `LogStreamingManager: failed to advance cursor for ` + + `destination ${dest.destinationId} logType="${logType}" ` + + `during backlog abandonment`, + err + ); + } + } + + logger.warn( + `LogStreamingManager: destination ${dest.destinationId} has been ` + + `unreachable for ${failingForHours}h ` + + `(${failState.consecutiveFailures} consecutive failures). ` + + `Discarding backlog of ~${totalDropped} log event(s) and ` + + `resuming from the current position. ` + + `Verify the destination URL and credentials.` + ); + } + + /** + * Forward all pending log records of a specific type for a destination. + * + * Fetches up to `BATCH_SIZE` records at a time. If the batch is full + * (indicating more records may exist) it loops immediately, inserting a + * short delay between consecutive requests to the remote endpoint. + * The loop is capped at `MAX_CATCHUP_BATCHES` to keep the poll cycle + * bounded. + */ + private async processLogType( + dest: EventStreamingDestination, + provider: LogDestinationProvider, + logType: LogType + ): Promise { + // Ensure a cursor row exists (creates one pointing at the current max + // id so we do not replay historical logs on first run) + const cursor = await this.getOrCreateCursor( + dest.destinationId, + logType, + dest.orgId + ); + + let lastSentId = cursor.lastSentId; + let batchCount = 0; + + while (batchCount < MAX_CATCHUP_BATCHES) { + const rows = await this.fetchLogs( + logType, + dest.orgId, + lastSentId, + BATCH_SIZE + ); + + if (rows.length === 0) break; + + const events = rows.map((row) => + this.rowToLogEvent(logType, row) + ); + + // Throws on failure – caught by the caller which applies back-off + await provider.send(events); + + lastSentId = rows[rows.length - 1].id; + await this.updateCursor(dest.destinationId, logType, lastSentId); + + batchCount++; + + if (rows.length < BATCH_SIZE) { + // Partial batch means we have caught up + break; + } + + // Full batch – there are likely more records; pause briefly before + // fetching the next batch to smooth out the HTTP request rate + if (batchCount < MAX_CATCHUP_BATCHES) { + await sleep(INTER_BATCH_DELAY_MS); + } + } + } + + // ------------------------------------------------------------------------- + // Cursor management + // ------------------------------------------------------------------------- + + private async getOrCreateCursor( + destinationId: number, + logType: LogType, + orgId: string + ): Promise<{ lastSentId: number }> { + // Try to read an existing cursor + const existing = await db + .select({ + lastSentId: eventStreamingCursors.lastSentId + }) + .from(eventStreamingCursors) + .where( + and( + eq(eventStreamingCursors.destinationId, destinationId), + eq(eventStreamingCursors.logType, logType) + ) + ) + .limit(1); + + if (existing.length > 0) { + return { lastSentId: existing[0].lastSentId }; + } + + // No cursor yet – this destination pre-dates the eager initialisation + // path (initializeCursorsForDestination). Seed at the current max id + // so we do not replay historical logs. + const initialId = await this.getCurrentMaxId(logType, orgId); + + // Use onConflictDoNothing in case of a rare race between two poll + // cycles both hitting this branch simultaneously. + await db + .insert(eventStreamingCursors) + .values({ + destinationId, + logType, + lastSentId: initialId, + lastSentAt: null + }) + .onConflictDoNothing(); + + logger.debug( + `LogStreamingManager: lazily initialised cursor for destination ${destinationId} ` + + `logType="${logType}" at id=${initialId} ` + + `(prefer initializeCursorsForDestination at creation time)` + ); + + return { lastSentId: initialId }; + } + + private async updateCursor( + destinationId: number, + logType: LogType, + lastSentId: number + ): Promise { + await db + .update(eventStreamingCursors) + .set({ + lastSentId, + lastSentAt: Date.now() + }) + .where( + and( + eq(eventStreamingCursors.destinationId, destinationId), + eq(eventStreamingCursors.logType, logType) + ) + ); + } + + /** + * Returns the current maximum `id` in the given log table for the org. + * Returns 0 when the table is empty. + */ + private async getCurrentMaxId( + logType: LogType, + orgId: string + ): Promise { + try { + switch (logType) { + case "request": { + const [row] = await logsDb + .select({ maxId: max(requestAuditLog.id) }) + .from(requestAuditLog) + .where(eq(requestAuditLog.orgId, orgId)); + return row?.maxId ?? 0; + } + case "action": { + const [row] = await logsDb + .select({ maxId: max(actionAuditLog.id) }) + .from(actionAuditLog) + .where(eq(actionAuditLog.orgId, orgId)); + return row?.maxId ?? 0; + } + case "access": { + const [row] = await logsDb + .select({ maxId: max(accessAuditLog.id) }) + .from(accessAuditLog) + .where(eq(accessAuditLog.orgId, orgId)); + return row?.maxId ?? 0; + } + case "connection": { + const [row] = await logsDb + .select({ maxId: max(connectionAuditLog.id) }) + .from(connectionAuditLog) + .where(eq(connectionAuditLog.orgId, orgId)); + return row?.maxId ?? 0; + } + } + } catch (err) { + logger.warn( + `LogStreamingManager: could not determine current max id for ` + + `logType="${logType}", defaulting to 0`, + err + ); + return 0; + } + } + + // ------------------------------------------------------------------------- + // Log fetching + // ------------------------------------------------------------------------- + + /** + * Fetch up to `limit` log rows with `id > afterId`, ordered by id ASC, + * filtered to the given organisation. + */ + private async fetchLogs( + logType: LogType, + orgId: string, + afterId: number, + limit: number + ): Promise & { id: number }>> { + switch (logType) { + case "request": + return (await logsDb + .select() + .from(requestAuditLog) + .where( + and( + eq(requestAuditLog.orgId, orgId), + gt(requestAuditLog.id, afterId) + ) + ) + .orderBy(requestAuditLog.id) + .limit(limit)) as Array< + Record & { id: number } + >; + + case "action": + return (await logsDb + .select() + .from(actionAuditLog) + .where( + and( + eq(actionAuditLog.orgId, orgId), + gt(actionAuditLog.id, afterId) + ) + ) + .orderBy(actionAuditLog.id) + .limit(limit)) as Array< + Record & { id: number } + >; + + case "access": + return (await logsDb + .select() + .from(accessAuditLog) + .where( + and( + eq(accessAuditLog.orgId, orgId), + gt(accessAuditLog.id, afterId) + ) + ) + .orderBy(accessAuditLog.id) + .limit(limit)) as Array< + Record & { id: number } + >; + + case "connection": + return (await logsDb + .select() + .from(connectionAuditLog) + .where( + and( + eq(connectionAuditLog.orgId, orgId), + gt(connectionAuditLog.id, afterId) + ) + ) + .orderBy(connectionAuditLog.id) + .limit(limit)) as Array< + Record & { id: number } + >; + } + } + + // ------------------------------------------------------------------------- + // Row → LogEvent conversion + // ------------------------------------------------------------------------- + + private rowToLogEvent( + logType: LogType, + row: Record & { id: number } + ): LogEvent { + // Determine the epoch-seconds timestamp for this row type + let timestamp: number; + switch (logType) { + case "request": + case "action": + case "access": + timestamp = + typeof row.timestamp === "number" ? row.timestamp : 0; + break; + case "connection": + timestamp = + typeof row.startedAt === "number" ? row.startedAt : 0; + break; + } + + const orgId = + typeof row.orgId === "string" ? row.orgId : ""; + + return { + id: row.id, + logType, + orgId, + timestamp, + data: row as Record + }; + } + + // ------------------------------------------------------------------------- + // Provider factory + // ------------------------------------------------------------------------- + + /** + * Instantiate the correct LogDestinationProvider for the given destination + * type string. Returns `null` for unknown types. + * + * To add a new provider: + * 1. Implement `LogDestinationProvider` in a new file under `providers/` + * 2. Add a `case` here + */ + private createProvider( + type: string, + config: unknown + ): LogDestinationProvider | null { + switch (type) { + case "http": + return new HttpLogDestination(config as HttpConfig); + // Future providers: + // case "datadog": return new DatadogLogDestination(config as DatadogConfig); + default: + return null; + } + } + + // ------------------------------------------------------------------------- + // Back-off tracking + // ------------------------------------------------------------------------- + + private recordFailure(destinationId: number): void { + const current = this.failures.get(destinationId) ?? { + consecutiveFailures: 0, + nextRetryAt: 0, + // Stamp the very first failure so we can measure total outage duration + firstFailedAt: Date.now() + }; + + current.consecutiveFailures += 1; + + const scheduleIdx = Math.min( + current.consecutiveFailures - 1, + BACKOFF_SCHEDULE_MS.length - 1 + ); + const backoffMs = BACKOFF_SCHEDULE_MS[scheduleIdx]; + current.nextRetryAt = Date.now() + backoffMs; + + this.failures.set(destinationId, current); + + logger.warn( + `LogStreamingManager: destination ${destinationId} failed ` + + `(consecutive #${current.consecutiveFailures}), ` + + `backing off for ${backoffMs / 1000}s` + ); + } + + // ------------------------------------------------------------------------- + // DB helpers + // ------------------------------------------------------------------------- + + private async loadEnabledDestinations(): Promise< + EventStreamingDestination[] + > { + try { + return await db + .select() + .from(eventStreamingDestinations) + .where(eq(eventStreamingDestinations.enabled, true)); + } catch (err) { + logger.error( + "LogStreamingManager: failed to load destinations", + err + ); + return []; + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} \ No newline at end of file diff --git a/server/private/lib/logStreaming/index.ts b/server/private/lib/logStreaming/index.ts new file mode 100644 index 000000000..619809771 --- /dev/null +++ b/server/private/lib/logStreaming/index.ts @@ -0,0 +1,34 @@ +/* + * 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 { build } from "@server/build"; +import { LogStreamingManager } from "./LogStreamingManager"; + +/** + * Module-level singleton. Importing this module is sufficient to start the + * streaming manager – no explicit init call required by the caller. + * + * The manager registers a non-blocking timer (unref'd) so it will not keep + * the Node.js event loop alive on its own. Call `logStreamingManager.shutdown()` + * during graceful shutdown to drain any in-progress poll and release resources. + */ +export const logStreamingManager = new LogStreamingManager(); + +if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here + logStreamingManager.start(); +} + +export { LogStreamingManager } from "./LogStreamingManager"; +export type { LogDestinationProvider } from "./providers/LogDestinationProvider"; +export { HttpLogDestination } from "./providers/HttpLogDestination"; +export * from "./types"; diff --git a/server/private/lib/logStreaming/providers/HttpLogDestination.ts b/server/private/lib/logStreaming/providers/HttpLogDestination.ts new file mode 100644 index 000000000..5e149f814 --- /dev/null +++ b/server/private/lib/logStreaming/providers/HttpLogDestination.ts @@ -0,0 +1,322 @@ +/* + * 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 logger from "@server/logger"; +import { LogEvent, HttpConfig, PayloadFormat } from "../types"; +import { LogDestinationProvider } from "./LogDestinationProvider"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Maximum time (ms) to wait for a single HTTP response. */ +const REQUEST_TIMEOUT_MS = 30_000; + +/** Default payload format when none is specified in the config. */ +const DEFAULT_FORMAT: PayloadFormat = "json_array"; + +// --------------------------------------------------------------------------- +// HttpLogDestination +// --------------------------------------------------------------------------- + +/** + * Forwards a batch of log events to an arbitrary HTTP endpoint via a single + * POST request per batch. + * + * **Payload format** + * + * **Payload formats** (controlled by `config.format`): + * + * - `json_array` (default) — one POST per batch, body is a JSON array: + * ```json + * [ + * { "event": "request", "timestamp": "2024-01-01T00:00:00.000Z", "data": { … } }, + * … + * ] + * ``` + * `Content-Type: application/json` + * + * - `ndjson` — one POST per batch, body is newline-delimited JSON (one object + * per line, no outer array). Required by Splunk HEC, Elastic/OpenSearch, + * and Grafana Loki: + * ``` + * {"event":"request","timestamp":"…","data":{…}} + * {"event":"action","timestamp":"…","data":{…}} + * ``` + * `Content-Type: application/x-ndjson` + * + * - `json_single` — one POST **per event**, body is a plain JSON object. + * Use only for endpoints that cannot handle batches at all. + * + * With a body template each event is rendered through the template before + * serialisation. Template placeholders: + * - `{{event}}` → the LogType string ("request", "action", etc.) + * - `{{timestamp}}` → ISO-8601 UTC datetime string + * - `{{data}}` → raw inline JSON object (**no surrounding quotes**) + * + * Example template: + * ``` + * { "event": "{{event}}", "ts": "{{timestamp}}", "payload": {{data}} } + * ``` + */ +export class HttpLogDestination implements LogDestinationProvider { + readonly type = "http"; + + private readonly config: HttpConfig; + + constructor(config: HttpConfig) { + this.config = config; + } + + // ----------------------------------------------------------------------- + // LogDestinationProvider implementation + // ----------------------------------------------------------------------- + + async send(events: LogEvent[]): Promise { + if (events.length === 0) return; + + const format = this.config.format ?? DEFAULT_FORMAT; + + if (format === "json_single") { + // One HTTP POST per event – send sequentially so a failure on one + // event throws and lets the manager retry the whole batch from the + // same cursor position. + for (const event of events) { + await this.postRequest( + this.buildSingleBody(event), + "application/json" + ); + } + return; + } + + if (format === "ndjson") { + const body = this.buildNdjsonBody(events); + await this.postRequest(body, "application/x-ndjson"); + return; + } + + // json_array (default) + const body = JSON.stringify(this.buildArrayPayload(events)); + await this.postRequest(body, "application/json"); + } + + // ----------------------------------------------------------------------- + // Internal HTTP sender + // ----------------------------------------------------------------------- + + private async postRequest( + body: string, + contentType: string + ): Promise { + const headers = this.buildHeaders(contentType); + + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS + ); + + let response: Response; + try { + response = await fetch(this.config.url, { + method: "POST", + headers, + body, + signal: controller.signal + }); + } catch (err: unknown) { + const isAbort = + err instanceof Error && err.name === "AbortError"; + if (isAbort) { + throw new Error( + `HttpLogDestination: request to "${this.config.url}" timed out after ${REQUEST_TIMEOUT_MS} ms` + ); + } + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `HttpLogDestination: request to "${this.config.url}" failed – ${msg}` + ); + } finally { + clearTimeout(timeoutHandle); + } + + if (!response.ok) { + // Try to include a snippet of the response body in the error so + // operators can diagnose auth or schema rejections. + let responseSnippet = ""; + try { + const text = await response.text(); + responseSnippet = text.slice(0, 300); + } catch { + // ignore – best effort + } + + throw new Error( + `HttpLogDestination: server at "${this.config.url}" returned ` + + `HTTP ${response.status} ${response.statusText}` + + (responseSnippet ? ` – ${responseSnippet}` : "") + ); + } + } + + // ----------------------------------------------------------------------- + // Header construction + // ----------------------------------------------------------------------- + + private buildHeaders(contentType: string): Record { + const headers: Record = { + "Content-Type": contentType + }; + + // Authentication + switch (this.config.authType) { + case "bearer": { + const token = this.config.bearerToken?.trim(); + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + break; + } + case "basic": { + const creds = this.config.basicCredentials?.trim(); + if (creds) { + const encoded = Buffer.from(creds).toString("base64"); + headers["Authorization"] = `Basic ${encoded}`; + } + break; + } + case "custom": { + const name = this.config.customHeaderName?.trim(); + const value = this.config.customHeaderValue ?? ""; + if (name) { + headers[name] = value; + } + break; + } + case "none": + default: + // No Authorization header + break; + } + + // Additional static headers (user-defined; may override Content-Type + // if the operator explicitly sets it, which is intentional). + for (const { key, value } of this.config.headers ?? []) { + const trimmedKey = key?.trim(); + if (trimmedKey) { + headers[trimmedKey] = value ?? ""; + } + } + + return headers; + } + + // ----------------------------------------------------------------------- + // Payload construction + // ----------------------------------------------------------------------- + + /** Single default event object (no surrounding array). */ + private buildEventObject(event: LogEvent): unknown { + if (this.config.useBodyTemplate && this.config.bodyTemplate?.trim()) { + return this.renderTemplate(this.config.bodyTemplate!, event); + } + return { + event: event.logType, + timestamp: epochSecondsToIso(event.timestamp), + data: event.data + }; + } + + /** JSON array payload – used for `json_array` format. */ + private buildArrayPayload(events: LogEvent[]): unknown[] { + return events.map((e) => this.buildEventObject(e)); + } + + /** + * NDJSON payload – one JSON object per line, no outer array. + * Each line must be a complete, valid JSON object. + */ + private buildNdjsonBody(events: LogEvent[]): string { + return events + .map((e) => JSON.stringify(this.buildEventObject(e))) + .join("\n"); + } + + /** Single-event body – used for `json_single` format. */ + private buildSingleBody(event: LogEvent): string { + return JSON.stringify(this.buildEventObject(event)); + } + + /** + * Render a single event through the body template. + * + * The three placeholder tokens are replaced in a specific order to avoid + * accidental double-replacement: + * + * 1. `{{data}}` → raw JSON (may contain `{{` characters in values) + * 2. `{{event}}` → safe string + * 3. `{{timestamp}}` → safe ISO string + * + * If the rendered string is not valid JSON we fall back to returning it as + * a plain string so the batch still makes it out and the operator can + * inspect the template. + */ + private renderTemplate(template: string, event: LogEvent): unknown { + const isoTimestamp = epochSecondsToIso(event.timestamp); + const dataJson = JSON.stringify(event.data); + + // Replace {{data}} first because its JSON value might legitimately + // contain the substrings "{{event}}" or "{{timestamp}}" inside string + // fields – those should NOT be re-expanded. + const rendered = template + .replace(/\{\{data\}\}/g, dataJson) + .replace(/\{\{event\}\}/g, escapeJsonString(event.logType)) + .replace( + /\{\{timestamp\}\}/g, + escapeJsonString(isoTimestamp) + ); + + try { + return JSON.parse(rendered); + } catch { + logger.warn( + `HttpLogDestination: body template produced invalid JSON for ` + + `event type "${event.logType}" destined for "${this.config.url}". ` + + `Sending rendered template as a raw string. ` + + `Check your template syntax – specifically that {{data}} is ` + + `NOT wrapped in quotes.` + ); + return rendered; + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function epochSecondsToIso(epochSeconds: number): string { + return new Date(epochSeconds * 1000).toISOString(); +} + +/** + * Escape a string value so it can be safely substituted into the interior of + * a JSON string literal (i.e. between existing `"` quotes in the template). + * This prevents a crafted logType or timestamp from breaking out of its + * string context in the rendered template. + */ +function escapeJsonString(value: string): string { + // JSON.stringify produces `""` – strip the outer quotes. + return JSON.stringify(value).slice(1, -1); +} \ No newline at end of file diff --git a/server/private/lib/logStreaming/providers/LogDestinationProvider.ts b/server/private/lib/logStreaming/providers/LogDestinationProvider.ts new file mode 100644 index 000000000..d09be320b --- /dev/null +++ b/server/private/lib/logStreaming/providers/LogDestinationProvider.ts @@ -0,0 +1,44 @@ +/* + * 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 { LogEvent } from "../types"; + +/** + * Common interface that every log-forwarding backend must implement. + * + * Adding a new destination type (e.g. Datadog, Splunk, Kafka) is as simple as + * creating a class that satisfies this interface and registering it inside + * LogStreamingManager.createProvider(). + */ +export interface LogDestinationProvider { + /** + * The string identifier that matches the `type` column in the + * `eventStreamingDestinations` table (e.g. "http", "datadog"). + */ + readonly type: string; + + /** + * Forward a batch of log events to the destination. + * + * Implementations should: + * - Treat the call as atomic: either all events are accepted or an error + * is thrown so the caller can retry / back off. + * - Respect the timeout contract expected by the manager (default 30 s). + * - NOT swallow errors – the manager relies on thrown exceptions to track + * failure state and apply exponential back-off. + * + * @param events A non-empty array of normalised log events to forward. + * @throws Any network, authentication, or serialisation error. + */ + send(events: LogEvent[]): Promise; +} \ No newline at end of file diff --git a/server/private/lib/logStreaming/types.ts b/server/private/lib/logStreaming/types.ts new file mode 100644 index 000000000..03fe88cad --- /dev/null +++ b/server/private/lib/logStreaming/types.ts @@ -0,0 +1,134 @@ +/* + * 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. + */ + +// --------------------------------------------------------------------------- +// Log type identifiers +// --------------------------------------------------------------------------- + +export type LogType = "request" | "action" | "access" | "connection"; + +export const LOG_TYPES: LogType[] = [ + "request", + "action", + "access", + "connection" +]; + +// --------------------------------------------------------------------------- +// A normalised event ready to be forwarded to a destination +// --------------------------------------------------------------------------- + +export interface LogEvent { + /** The auto-increment primary key from the source table */ + id: number; + /** Which log table this event came from */ + logType: LogType; + /** The organisation that owns this event */ + orgId: string; + /** Unix epoch seconds – taken from the record's own timestamp field */ + timestamp: number; + /** Full row data from the source table, serialised as a plain object */ + data: Record; +} + +// --------------------------------------------------------------------------- +// A batch of events destined for a single streaming target +// --------------------------------------------------------------------------- + +export interface LogBatch { + destinationId: number; + logType: LogType; + events: LogEvent[]; +} + +// --------------------------------------------------------------------------- +// HTTP destination configuration (mirrors HttpConfig in the UI component) +// --------------------------------------------------------------------------- + +export type AuthType = "none" | "bearer" | "basic" | "custom"; + +/** + * Controls how the batch of events is serialised into the HTTP request body. + * + * - `json_array` – `[{…}, {…}]` — default; one POST per batch wrapped in a + * JSON array. Works with most generic webhooks and Datadog. + * - `ndjson` – `{…}\n{…}` — newline-delimited JSON, one object per + * line. Required by Splunk HEC, Elastic/OpenSearch, Loki. + * - `json_single` – one HTTP POST per event, body is a plain JSON object. + * Use only for endpoints that cannot handle batches at all. + */ +export type PayloadFormat = "json_array" | "ndjson" | "json_single"; + +export interface HttpConfig { + /** Human-readable label for the destination */ + name: string; + /** Target URL that will receive POST requests */ + url: string; + /** Authentication strategy to use */ + authType: AuthType; + /** Used when authType === "bearer" */ + bearerToken?: string; + /** Used when authType === "basic" – must be "username:password" */ + basicCredentials?: string; + /** Used when authType === "custom" – header name */ + customHeaderName?: string; + /** Used when authType === "custom" – header value */ + customHeaderValue?: string; + /** Additional static headers appended to every request */ + headers: Array<{ key: string; value: string }>; + /** Whether to render a custom body template instead of the default shape */ + /** + * How events are serialised into the request body. + * Defaults to `"json_array"` when absent. + */ + format?: PayloadFormat; + useBodyTemplate: boolean; + /** + * Handlebars-style template for the JSON body of each event. + * + * Supported placeholders: + * {{event}} – the LogType string ("request", "action", etc.) + * {{timestamp}} – ISO-8601 UTC string derived from the event's timestamp + * {{data}} – raw JSON object (no surrounding quotes) of the full row + * + * Example: + * { "event": "{{event}}", "ts": "{{timestamp}}", "payload": {{data}} } + */ + bodyTemplate?: string; +} + +// --------------------------------------------------------------------------- +// Per-destination per-log-type cursor (reflects the DB table) +// --------------------------------------------------------------------------- + +export interface StreamingCursor { + destinationId: number; + logType: LogType; + /** The `id` of the last row that was successfully forwarded */ + lastSentId: number; + /** Epoch milliseconds of the last successful send (or null if never sent) */ + lastSentAt: number | null; +} + +// --------------------------------------------------------------------------- +// In-memory failure / back-off state tracked per destination +// --------------------------------------------------------------------------- + +export interface DestinationFailureState { + /** How many consecutive send failures have occurred */ + consecutiveFailures: number; + /** Date.now() value after which the destination may be retried */ + nextRetryAt: number; + /** Date.now() value of the very first failure in the current streak */ + firstFailedAt: number; +} \ No newline at end of file diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 0ce6d0272..54260009b 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -57,7 +57,10 @@ export const privateConfigSchema = z.object({ .object({ host: z.string(), port: portSchema, - password: z.string().optional(), + password: z + .string() + .optional() + .transform(getEnvOrYaml("REDIS_PASSWORD")), db: z.int().nonnegative().optional().default(0), replicas: z .array( diff --git a/server/private/middlewares/verifyIdpAccess.ts b/server/private/middlewares/verifyIdpAccess.ts index 410956844..2dbc1b8ff 100644 --- a/server/private/middlewares/verifyIdpAccess.ts +++ b/server/private/middlewares/verifyIdpAccess.ts @@ -13,9 +13,10 @@ import { Request, Response, NextFunction } from "express"; import { userOrgs, db, idp, idpOrg } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyIdpAccess( req: Request, @@ -84,8 +85,10 @@ export async function verifyIdpAccess( ); } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + idpRes.idpOrg.orgId + ); return next(); } catch (error) { diff --git a/server/private/middlewares/verifyRemoteExitNodeAccess.ts b/server/private/middlewares/verifyRemoteExitNodeAccess.ts index a2cd2bace..7d6128d8f 100644 --- a/server/private/middlewares/verifyRemoteExitNodeAccess.ts +++ b/server/private/middlewares/verifyRemoteExitNodeAccess.ts @@ -12,11 +12,12 @@ */ import { Request, Response, NextFunction } from "express"; -import { db, exitNodeOrgs, exitNodes, remoteExitNodes } from "@server/db"; -import { sites, userOrgs, userSites, roleSites, roles } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { db, exitNodeOrgs, remoteExitNodes } from "@server/db"; +import { userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyRemoteExitNodeAccess( req: Request, @@ -103,8 +104,10 @@ export async function verifyRemoteExitNodeAccess( ); } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + exitNodeOrg.orgId + ); return next(); } catch (error) { diff --git a/server/private/routers/auditLogs/exportConnectionAuditLog.ts b/server/private/routers/auditLogs/exportConnectionAuditLog.ts new file mode 100644 index 000000000..9349528ad --- /dev/null +++ b/server/private/routers/auditLogs/exportConnectionAuditLog.ts @@ -0,0 +1,99 @@ +/* + * 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 { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { OpenAPITags } from "@server/openApi"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { + queryConnectionAuditLogsParams, + queryConnectionAuditLogsQuery, + queryConnection, + countConnectionQuery +} from "./queryConnectionAuditLog"; +import { generateCSV } from "@server/routers/auditLogs/generateCSV"; +import { MAX_EXPORT_LIMIT } from "@server/routers/auditLogs"; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/connection/export", + description: "Export the connection audit log for an organization as CSV", + tags: [OpenAPITags.Logs], + request: { + query: queryConnectionAuditLogsQuery, + params: queryConnectionAuditLogsParams + }, + responses: {} +}); + +export async function exportConnectionAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryConnectionAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryConnectionAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + const [{ count }] = await countConnectionQuery(data); + if (count > MAX_EXPORT_LIMIT) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.` + ) + ); + } + + const baseQuery = queryConnection(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const csvData = generateCSV(log); + + res.setHeader("Content-Type", "text/csv"); + res.setHeader( + "Content-Disposition", + `attachment; filename="connection-audit-logs-${data.orgId}-${Date.now()}.csv"` + ); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index e1849a617..122455fea 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -15,3 +15,5 @@ export * from "./queryActionAuditLog"; export * from "./exportActionAuditLog"; export * from "./queryAccessAuditLog"; export * from "./exportAccessAuditLog"; +export * from "./queryConnectionAuditLog"; +export * from "./exportConnectionAuditLog"; diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index f0f45a826..f9951c1ab 100644 --- a/server/private/routers/auditLogs/queryAccessAuditLog.ts +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -11,11 +11,11 @@ * This file is not licensed under the AGPLv3. */ -import { accessAuditLog, logsDb, resources, db, primaryDb } from "@server/db"; +import { accessAuditLog, logsDb, resources, siteResources, db, primaryDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; +import { eq, gt, lt, and, count, desc, inArray, isNull } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; @@ -122,6 +122,7 @@ export function queryAccess(data: Q) { actorType: accessAuditLog.actorType, actorId: accessAuditLog.actorId, resourceId: accessAuditLog.resourceId, + siteResourceId: accessAuditLog.siteResourceId, ip: accessAuditLog.ip, location: accessAuditLog.location, userAgent: accessAuditLog.userAgent, @@ -136,37 +137,73 @@ export function queryAccess(data: Q) { } async function enrichWithResourceDetails(logs: Awaited>) { - // If logs database is the same as main database, we can do a join - // Otherwise, we need to fetch resource details separately const resourceIds = logs .map(log => log.resourceId) .filter((id): id is number => id !== null && id !== undefined); - if (resourceIds.length === 0) { + const siteResourceIds = logs + .filter(log => log.resourceId == null && log.siteResourceId != null) + .map(log => log.siteResourceId) + .filter((id): id is number => id !== null && id !== undefined); + + if (resourceIds.length === 0 && siteResourceIds.length === 0) { return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); } - // Fetch resource details from main database - const resourceDetails = await primaryDb - .select({ - resourceId: resources.resourceId, - name: resources.name, - niceId: resources.niceId - }) - .from(resources) - .where(inArray(resources.resourceId, resourceIds)); + const resourceMap = new Map(); - // Create a map for quick lookup - const resourceMap = new Map( - resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }]) - ); + if (resourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + resourceId: resources.resourceId, + name: resources.name, + niceId: resources.niceId + }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + + for (const r of resourceDetails) { + resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId }); + } + } + + const siteResourceMap = new Map(); + + if (siteResourceIds.length > 0) { + const siteResourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + niceId: siteResources.niceId + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + for (const r of siteResourceDetails) { + siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId }); + } + } // Enrich logs with resource details - return logs.map(log => ({ - ...log, - resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null, - resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null - })); + return logs.map(log => { + if (log.resourceId != null) { + const details = resourceMap.get(log.resourceId); + return { + ...log, + resourceName: details?.name ?? null, + resourceNiceId: details?.niceId ?? null + }; + } else if (log.siteResourceId != null) { + const details = siteResourceMap.get(log.siteResourceId); + return { + ...log, + resourceId: log.siteResourceId, + resourceName: details?.name ?? null, + resourceNiceId: details?.niceId ?? null + }; + } + return { ...log, resourceName: null, resourceNiceId: null }; + }); } export function countAccessQuery(data: Q) { @@ -212,11 +249,23 @@ async function queryUniqueFilterAttributes( .from(accessAuditLog) .where(baseConditions); + // Get unique siteResources (only for logs where resourceId is null) + const uniqueSiteResources = await logsDb + .selectDistinct({ + id: accessAuditLog.siteResourceId + }) + .from(accessAuditLog) + .where(and(baseConditions, isNull(accessAuditLog.resourceId))); + // Fetch resource names from main database for the unique resource IDs const resourceIds = uniqueResources .map(row => row.id) .filter((id): id is number => id !== null); + const siteResourceIds = uniqueSiteResources + .map(row => row.id) + .filter((id): id is number => id !== null); + let resourcesWithNames: Array<{ id: number; name: string | null }> = []; if (resourceIds.length > 0) { @@ -228,10 +277,31 @@ async function queryUniqueFilterAttributes( .from(resources) .where(inArray(resources.resourceId, resourceIds)); - resourcesWithNames = resourceDetails.map(r => ({ - id: r.resourceId, - name: r.name - })); + resourcesWithNames = [ + ...resourcesWithNames, + ...resourceDetails.map(r => ({ + id: r.resourceId, + name: r.name + })) + ]; + } + + if (siteResourceIds.length > 0) { + const siteResourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + resourcesWithNames = [ + ...resourcesWithNames, + ...siteResourceDetails.map(r => ({ + id: r.siteResourceId, + name: r.name + })) + ]; } return { diff --git a/server/private/routers/auditLogs/queryConnectionAuditLog.ts b/server/private/routers/auditLogs/queryConnectionAuditLog.ts new file mode 100644 index 000000000..b638ed488 --- /dev/null +++ b/server/private/routers/auditLogs/queryConnectionAuditLog.ts @@ -0,0 +1,524 @@ +/* + * 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 { + connectionAuditLog, + logsDb, + siteResources, + sites, + clients, + users, + primaryDb +} from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; + +export const queryConnectionAuditLogsQuery = z.object({ + // iso string just validate its a parseable date + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .prefault(() => getSevenDaysAgo().toISOString()) + .openapi({ + type: "string", + format: "date-time", + description: + "Start time as ISO date string (defaults to 7 days ago)" + }), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeEnd must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .optional() + .prefault(() => new Date().toISOString()) + .openapi({ + type: "string", + format: "date-time", + description: + "End time as ISO date string (defaults to current time)" + }), + protocol: z.string().optional(), + sourceAddr: z.string().optional(), + destAddr: z.string().optional(), + clientId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + siteId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + siteResourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + userId: z.string().optional(), + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +export const queryConnectionAuditLogsParams = z.object({ + orgId: z.string() +}); + +export const queryConnectionAuditLogsCombined = + queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(connectionAuditLog.startedAt, data.timeStart), + lt(connectionAuditLog.startedAt, data.timeEnd), + eq(connectionAuditLog.orgId, data.orgId), + data.protocol + ? eq(connectionAuditLog.protocol, data.protocol) + : undefined, + data.sourceAddr + ? eq(connectionAuditLog.sourceAddr, data.sourceAddr) + : undefined, + data.destAddr + ? eq(connectionAuditLog.destAddr, data.destAddr) + : undefined, + data.clientId + ? eq(connectionAuditLog.clientId, data.clientId) + : undefined, + data.siteId + ? eq(connectionAuditLog.siteId, data.siteId) + : undefined, + data.siteResourceId + ? eq(connectionAuditLog.siteResourceId, data.siteResourceId) + : undefined, + data.userId + ? eq(connectionAuditLog.userId, data.userId) + : undefined + ); +} + +export function queryConnection(data: Q) { + return logsDb + .select({ + sessionId: connectionAuditLog.sessionId, + siteResourceId: connectionAuditLog.siteResourceId, + orgId: connectionAuditLog.orgId, + siteId: connectionAuditLog.siteId, + clientId: connectionAuditLog.clientId, + userId: connectionAuditLog.userId, + sourceAddr: connectionAuditLog.sourceAddr, + destAddr: connectionAuditLog.destAddr, + protocol: connectionAuditLog.protocol, + startedAt: connectionAuditLog.startedAt, + endedAt: connectionAuditLog.endedAt, + bytesTx: connectionAuditLog.bytesTx, + bytesRx: connectionAuditLog.bytesRx + }) + .from(connectionAuditLog) + .where(getWhere(data)) + .orderBy( + desc(connectionAuditLog.startedAt), + desc(connectionAuditLog.id) + ); +} + +export function countConnectionQuery(data: Q) { + const countQuery = logsDb + .select({ count: count() }) + .from(connectionAuditLog) + .where(getWhere(data)); + return countQuery; +} + +async function enrichWithDetails( + logs: Awaited> +) { + // Collect unique IDs from logs + const siteResourceIds = [ + ...new Set( + logs + .map((log) => log.siteResourceId) + .filter((id): id is number => id !== null && id !== undefined) + ) + ]; + const siteIds = [ + ...new Set( + logs + .map((log) => log.siteId) + .filter((id): id is number => id !== null && id !== undefined) + ) + ]; + const clientIds = [ + ...new Set( + logs + .map((log) => log.clientId) + .filter((id): id is number => id !== null && id !== undefined) + ) + ]; + const userIds = [ + ...new Set( + logs + .map((log) => log.userId) + .filter((id): id is string => id !== null && id !== undefined) + ) + ]; + + // Fetch resource details from main database + const resourceMap = new Map< + number, + { name: string; niceId: string } + >(); + if (siteResourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + niceId: siteResources.niceId + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + for (const r of resourceDetails) { + resourceMap.set(r.siteResourceId, { + name: r.name, + niceId: r.niceId + }); + } + } + + // Fetch site details from main database + const siteMap = new Map(); + if (siteIds.length > 0) { + const siteDetails = await primaryDb + .select({ + siteId: sites.siteId, + name: sites.name, + niceId: sites.niceId + }) + .from(sites) + .where(inArray(sites.siteId, siteIds)); + + for (const s of siteDetails) { + siteMap.set(s.siteId, { name: s.name, niceId: s.niceId }); + } + } + + // Fetch client details from main database + const clientMap = new Map< + number, + { name: string; niceId: string; type: string } + >(); + if (clientIds.length > 0) { + const clientDetails = await primaryDb + .select({ + clientId: clients.clientId, + name: clients.name, + niceId: clients.niceId, + type: clients.type + }) + .from(clients) + .where(inArray(clients.clientId, clientIds)); + + for (const c of clientDetails) { + clientMap.set(c.clientId, { + name: c.name, + niceId: c.niceId, + type: c.type + }); + } + } + + // Fetch user details from main database + const userMap = new Map< + string, + { email: string | null } + >(); + if (userIds.length > 0) { + const userDetails = await primaryDb + .select({ + userId: users.userId, + email: users.email + }) + .from(users) + .where(inArray(users.userId, userIds)); + + for (const u of userDetails) { + userMap.set(u.userId, { email: u.email }); + } + } + + // Enrich logs with details + return logs.map((log) => ({ + ...log, + resourceName: log.siteResourceId + ? resourceMap.get(log.siteResourceId)?.name ?? null + : null, + resourceNiceId: log.siteResourceId + ? resourceMap.get(log.siteResourceId)?.niceId ?? null + : null, + siteName: log.siteId + ? siteMap.get(log.siteId)?.name ?? null + : null, + siteNiceId: log.siteId + ? siteMap.get(log.siteId)?.niceId ?? null + : null, + clientName: log.clientId + ? clientMap.get(log.clientId)?.name ?? null + : null, + clientNiceId: log.clientId + ? clientMap.get(log.clientId)?.niceId ?? null + : null, + clientType: log.clientId + ? clientMap.get(log.clientId)?.type ?? null + : null, + userEmail: log.userId + ? userMap.get(log.userId)?.email ?? null + : null + })); +} + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(connectionAuditLog.startedAt, timeStart), + lt(connectionAuditLog.startedAt, timeEnd), + eq(connectionAuditLog.orgId, orgId) + ); + + // Get unique protocols + const uniqueProtocols = await logsDb + .selectDistinct({ + protocol: connectionAuditLog.protocol + }) + .from(connectionAuditLog) + .where(baseConditions); + + // Get unique destination addresses + const uniqueDestAddrs = await logsDb + .selectDistinct({ + destAddr: connectionAuditLog.destAddr + }) + .from(connectionAuditLog) + .where(baseConditions); + + // Get unique client IDs + const uniqueClients = await logsDb + .selectDistinct({ + clientId: connectionAuditLog.clientId + }) + .from(connectionAuditLog) + .where(baseConditions); + + // Get unique resource IDs + const uniqueResources = await logsDb + .selectDistinct({ + siteResourceId: connectionAuditLog.siteResourceId + }) + .from(connectionAuditLog) + .where(baseConditions); + + // Get unique user IDs + const uniqueUsers = await logsDb + .selectDistinct({ + userId: connectionAuditLog.userId + }) + .from(connectionAuditLog) + .where(baseConditions); + + // Enrich client IDs with names from main database + const clientIds = uniqueClients + .map((row) => row.clientId) + .filter((id): id is number => id !== null); + + let clientsWithNames: Array<{ id: number; name: string }> = []; + if (clientIds.length > 0) { + const clientDetails = await primaryDb + .select({ + clientId: clients.clientId, + name: clients.name + }) + .from(clients) + .where(inArray(clients.clientId, clientIds)); + + clientsWithNames = clientDetails.map((c) => ({ + id: c.clientId, + name: c.name + })); + } + + // Enrich resource IDs with names from main database + const resourceIds = uniqueResources + .map((row) => row.siteResourceId) + .filter((id): id is number => id !== null); + + let resourcesWithNames: Array<{ id: number; name: string | null }> = []; + if (resourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, resourceIds)); + + resourcesWithNames = resourceDetails.map((r) => ({ + id: r.siteResourceId, + name: r.name + })); + } + + // Enrich user IDs with emails from main database + const userIdsList = uniqueUsers + .map((row) => row.userId) + .filter((id): id is string => id !== null); + + let usersWithEmails: Array<{ id: string; email: string | null }> = []; + if (userIdsList.length > 0) { + const userDetails = await primaryDb + .select({ + userId: users.userId, + email: users.email + }) + .from(users) + .where(inArray(users.userId, userIdsList)); + + usersWithEmails = userDetails.map((u) => ({ + id: u.userId, + email: u.email + })); + } + + return { + protocols: uniqueProtocols + .map((row) => row.protocol) + .filter((protocol): protocol is string => protocol !== null), + destAddrs: uniqueDestAddrs + .map((row) => row.destAddr) + .filter((addr): addr is string => addr !== null), + clients: clientsWithNames, + resources: resourcesWithNames, + users: usersWithEmails + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/connection", + description: "Query the connection audit log for an organization", + tags: [OpenAPITags.Logs], + request: { + query: queryConnectionAuditLogsQuery, + params: queryConnectionAuditLogsParams + }, + responses: {} +}); + +export async function queryConnectionAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryConnectionAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const parsedParams = queryConnectionAuditLogsParams.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryConnection(data); + + const logsRaw = await baseQuery.limit(data.limit).offset(data.offset); + + // Enrich with resource, site, client, and user details + const log = await enrichWithDetails(logsRaw); + + const totalCountResult = await countConnectionQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + success: true, + error: false, + message: "Connection audit logs retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 9536a87f0..d86e23cf0 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -26,9 +26,12 @@ import { orgs, resources, roles, - siteResources + siteResources, + userOrgRoles, + siteProvisioningKeyOrg, + siteProvisioningKeys, } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; /** * Get the maximum allowed retention days for a given tier @@ -117,6 +120,18 @@ async function capRetentionDays( ); } + // Cap action log retention if it exceeds the limit + if ( + org.settingsLogRetentionDaysConnection !== null && + org.settingsLogRetentionDaysConnection > maxRetentionDays + ) { + updates.settingsLogRetentionDaysConnection = maxRetentionDays; + needsUpdate = true; + logger.info( + `Capping connection log retention from ${org.settingsLogRetentionDaysConnection} to ${maxRetentionDays} days for org ${orgId}` + ); + } + // Apply updates if needed if (needsUpdate) { await db.update(orgs).set(updates).where(eq(orgs.orgId, orgId)); @@ -259,6 +274,10 @@ async function disableFeature( await disableActionLogs(orgId); break; + case TierFeature.ConnectionLogs: + await disableConnectionLogs(orgId); + break; + case TierFeature.RotateCredentials: await disableRotateCredentials(orgId); break; @@ -291,6 +310,14 @@ async function disableFeature( await disableSshPam(orgId); break; + case TierFeature.FullRbac: + await disableFullRbac(orgId); + break; + + case TierFeature.SiteProvisioningKeys: + await disableSiteProvisioningKeys(orgId); + break; + default: logger.warn( `Unknown feature ${feature} for org ${orgId}, skipping` @@ -326,6 +353,61 @@ async function disableSshPam(orgId: string): Promise { ); } +async function disableFullRbac(orgId: string): Promise { + logger.info(`Disabled full RBAC for org ${orgId}`); +} + +async function disableSiteProvisioningKeys(orgId: string): Promise { + const rows = await db + .select({ + siteProvisioningKeyId: + siteProvisioningKeyOrg.siteProvisioningKeyId + }) + .from(siteProvisioningKeyOrg) + .where(eq(siteProvisioningKeyOrg.orgId, orgId)); + + for (const { siteProvisioningKeyId } of rows) { + await db.transaction(async (trx) => { + await trx + .delete(siteProvisioningKeyOrg) + .where( + and( + eq( + siteProvisioningKeyOrg.siteProvisioningKeyId, + siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ); + + const remaining = await trx + .select() + .from(siteProvisioningKeyOrg) + .where( + eq( + siteProvisioningKeyOrg.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + + if (remaining.length === 0) { + await trx + .delete(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + } + }); + } + + logger.info( + `Removed site provisioning keys for org ${orgId} after tier downgrade` + ); +} + async function disableLoginPageBranding(orgId: string): Promise { const [existingBranding] = await db .select() @@ -392,6 +474,15 @@ async function disableActionLogs(orgId: string): Promise { logger.info(`Disabled action logs for org ${orgId}`); } +async function disableConnectionLogs(orgId: string): Promise { + await db + .update(orgs) + .set({ settingsLogRetentionDaysConnection: 0 }) + .where(eq(orgs.orgId, orgId)); + + logger.info(`Disabled connection logs for org ${orgId}`); +} + async function disableRotateCredentials(orgId: string): Promise {} async function disableMaintencePage(orgId: string): Promise { diff --git a/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts new file mode 100644 index 000000000..623f2d9e0 --- /dev/null +++ b/server/private/routers/eventStreamingDestination/createEventStreamingDestination.ts @@ -0,0 +1,138 @@ +/* + * 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 { db } from "@server/db"; +import { eventStreamingDestinations } from "@server/db"; +import { logStreamingManager } from "#private/lib/logStreaming"; +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"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.strictObject({ + type: z.string().nonempty(), + config: z.string().nonempty(), + enabled: z.boolean().optional().default(true), + sendConnectionLogs: z.boolean().optional().default(false), + sendRequestLogs: z.boolean().optional().default(false), + sendActionLogs: z.boolean().optional().default(false), + sendAccessLogs: z.boolean().optional().default(false) +}); + +export type CreateEventStreamingDestinationResponse = { + destinationId: number; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/event-streaming-destination", + description: "Create an event streaming destination for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createEventStreamingDestination( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { type, config, enabled } = parsedBody.data; + + const now = Date.now(); + + const [destination] = await db + .insert(eventStreamingDestinations) + .values({ + orgId, + type, + config, + enabled, + createdAt: now, + updatedAt: now, + sendAccessLogs: parsedBody.data.sendAccessLogs, + sendActionLogs: parsedBody.data.sendActionLogs, + sendConnectionLogs: parsedBody.data.sendConnectionLogs, + sendRequestLogs: parsedBody.data.sendRequestLogs + }) + .returning(); + + // Seed cursors at the current max row id for every log type so this + // destination only receives events written *after* it was created. + // Fire-and-forget: a failure here is non-fatal; the manager has a lazy + // fallback that will seed at the next poll if these rows are missing. + logStreamingManager + .initializeCursorsForDestination(destination.destinationId, orgId) + .catch((err) => + logger.error( + "createEventStreamingDestination: failed to initialise streaming cursors", + err + ) + ); + + return response(res, { + data: { + destinationId: destination.destinationId + }, + success: true, + error: false, + message: "Event streaming destination created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts new file mode 100644 index 000000000..d93bc4405 --- /dev/null +++ b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts @@ -0,0 +1,103 @@ +/* + * 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 { db } from "@server/db"; +import { eventStreamingDestinations } from "@server/db"; +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 { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + destinationId: z.coerce.number() + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/event-streaming-destination/{destinationId}", + description: "Delete an event streaming destination for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteEventStreamingDestination( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, destinationId } = parsedParams.data; + + const [existing] = await db + .select() + .from(eventStreamingDestinations) + .where( + and( + eq(eventStreamingDestinations.destinationId, destinationId), + eq(eventStreamingDestinations.orgId, orgId) + ) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Event streaming destination not found" + ) + ); + } + + await db + .delete(eventStreamingDestinations) + .where( + and( + eq(eventStreamingDestinations.destinationId, destinationId), + eq(eventStreamingDestinations.orgId, orgId) + ) + ); + + return response(res, { + data: null, + success: true, + error: false, + message: "Event streaming destination deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/eventStreamingDestination/index.ts b/server/private/routers/eventStreamingDestination/index.ts new file mode 100644 index 000000000..595e9595b --- /dev/null +++ b/server/private/routers/eventStreamingDestination/index.ts @@ -0,0 +1,17 @@ +/* + * 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 "./createEventStreamingDestination"; +export * from "./updateEventStreamingDestination"; +export * from "./deleteEventStreamingDestination"; +export * from "./listEventStreamingDestinations"; \ No newline at end of file diff --git a/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts new file mode 100644 index 000000000..b3f5ff149 --- /dev/null +++ b/server/private/routers/eventStreamingDestination/listEventStreamingDestinations.ts @@ -0,0 +1,144 @@ +/* + * 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 { db } from "@server/db"; +import { eventStreamingDestinations } from "@server/db"; +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 { eq, sql } from "drizzle-orm"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const querySchema = z.strictObject({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +export type ListEventStreamingDestinationsResponse = { + destinations: { + destinationId: number; + orgId: string; + type: string; + config: string; + enabled: boolean; + createdAt: number; + updatedAt: number; + sendConnectionLogs: boolean; + sendRequestLogs: boolean; + sendActionLogs: boolean; + sendAccessLogs: boolean; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +async function query(orgId: string, limit: number, offset: number) { + const res = await db + .select() + .from(eventStreamingDestinations) + .where(eq(eventStreamingDestinations.orgId, orgId)) + .orderBy(sql`${eventStreamingDestinations.createdAt} DESC`) + .limit(limit) + .offset(offset); + return res; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/event-streaming-destination", + description: "List all event streaming destinations for a specific organization.", + tags: [OpenAPITags.Org], + request: { + query: querySchema, + params: paramsSchema + }, + responses: {} +}); + +export async function listEventStreamingDestinations( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { orgId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const list = await query(orgId, limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(eventStreamingDestinations) + .where(eq(eventStreamingDestinations.orgId, orgId)); + + return response(res, { + data: { + destinations: list, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Event streaming destinations retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts new file mode 100644 index 000000000..3d3321824 --- /dev/null +++ b/server/private/routers/eventStreamingDestination/updateEventStreamingDestination.ts @@ -0,0 +1,153 @@ +/* + * 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 { db } from "@server/db"; +import { eventStreamingDestinations } from "@server/db"; +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 { and, eq } from "drizzle-orm"; + + +const paramsSchema = z + .object({ + orgId: z.string().nonempty(), + destinationId: z.coerce.number() + }) + .strict(); + +const bodySchema = z.strictObject({ + type: z.string().optional(), + config: z.string().optional(), + enabled: z.boolean().optional(), + sendConnectionLogs: z.boolean().optional(), + sendRequestLogs: z.boolean().optional(), + sendActionLogs: z.boolean().optional(), + sendAccessLogs: z.boolean().optional() +}); + +export type UpdateEventStreamingDestinationResponse = { + destinationId: number; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/event-streaming-destination/{destinationId}", + description: "Update an event streaming destination for a specific organization.", + tags: [OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateEventStreamingDestination( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, destinationId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const [existing] = await db + .select() + .from(eventStreamingDestinations) + .where( + and( + eq(eventStreamingDestinations.destinationId, destinationId), + eq(eventStreamingDestinations.orgId, orgId) + ) + ); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Event streaming destination not found" + ) + ); + } + + const { type, config, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data; + + const updateData: Record = { + updatedAt: Date.now() + }; + + if (type !== undefined) updateData.type = type; + if (config !== undefined) updateData.config = config; + if (enabled !== undefined) updateData.enabled = enabled; + if (sendAccessLogs !== undefined) updateData.sendAccessLogs = sendAccessLogs; + if (sendActionLogs !== undefined) updateData.sendActionLogs = sendActionLogs; + if (sendConnectionLogs !== undefined) updateData.sendConnectionLogs = sendConnectionLogs; + if (sendRequestLogs !== undefined) updateData.sendRequestLogs = sendRequestLogs; + + await db + .update(eventStreamingDestinations) + .set(updateData) + .where( + and( + eq(eventStreamingDestinations.destinationId, destinationId), + eq(eventStreamingDestinations.orgId, orgId) + ) + ); + + + return response(res, { + data: { + destinationId + }, + success: true, + error: false, + message: "Event streaming destination updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index df8ea8cbb..4410a44c8 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -26,6 +26,9 @@ import * as misc from "#private/routers/misc"; import * as reKey from "#private/routers/re-key"; import * as approval from "#private/routers/approvals"; import * as ssh from "#private/routers/ssh"; +import * as user from "#private/routers/user"; +import * as siteProvisioning from "#private/routers/siteProvisioning"; +import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import { verifyOrgAccess, @@ -33,7 +36,11 @@ import { verifyUserIsServerAdmin, verifySiteAccess, verifyClientAccess, - verifyLimits + verifyLimits, + verifyRoleAccess, + verifyUserAccess, + verifyUserCanSetUserOrgRoles, + verifySiteProvisioningKeyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -478,6 +485,25 @@ authenticated.get( logs.exportAccessAuditLogs ); +authenticated.get( + "/org/:orgId/logs/connection", + verifyValidLicense, + verifyValidSubscription(tierMatrix.connectionLogs), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logs.queryConnectionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/connection/export", + verifyValidLicense, + verifyValidSubscription(tierMatrix.logExport), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportConnectionAuditLogs +); + authenticated.post( "/re-key/:clientId/regenerate-client-secret", verifyClientAccess, // this is first to set the org id @@ -518,3 +544,111 @@ authenticated.post( // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata ssh.signSshKey ); + +authenticated.post( + "/user/:userId/add-role/:roleId", + verifyRoleAccess, + verifyUserAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.addUserRole), + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole +); + +authenticated.delete( + "/user/:userId/remove-role/:roleId", + verifyRoleAccess, + verifyUserAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.removeUserRole), + logActionAudit(ActionsEnum.removeUserRole), + user.removeUserRole +); + +authenticated.post( + "/user/:userId/org/:orgId/roles", + verifyOrgAccess, + verifyUserAccess, + verifyLimits, + verifyUserCanSetUserOrgRoles(), + logActionAudit(ActionsEnum.setUserOrgRoles), + user.setUserOrgRoles +); + +authenticated.put( + "/org/:orgId/site-provisioning-key", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createSiteProvisioningKey), + logActionAudit(ActionsEnum.createSiteProvisioningKey), + siteProvisioning.createSiteProvisioningKey +); + +authenticated.get( + "/org/:orgId/site-provisioning-keys", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listSiteProvisioningKeys), + siteProvisioning.listSiteProvisioningKeys +); + +authenticated.delete( + "/org/:orgId/site-provisioning-key/:siteProvisioningKeyId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifySiteProvisioningKeyAccess, + verifyUserHasAction(ActionsEnum.deleteSiteProvisioningKey), + logActionAudit(ActionsEnum.deleteSiteProvisioningKey), + siteProvisioning.deleteSiteProvisioningKey +); + +authenticated.patch( + "/org/:orgId/site-provisioning-key/:siteProvisioningKeyId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifySiteProvisioningKeyAccess, + verifyUserHasAction(ActionsEnum.updateSiteProvisioningKey), + logActionAudit(ActionsEnum.updateSiteProvisioningKey), + siteProvisioning.updateSiteProvisioningKey +); + +authenticated.put( + "/org/:orgId/event-streaming-destination", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createEventStreamingDestination), + logActionAudit(ActionsEnum.createEventStreamingDestination), + eventStreamingDestination.createEventStreamingDestination +); + +authenticated.post( + "/org/:orgId/event-streaming-destination/:destinationId", + verifyValidLicense, + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.updateEventStreamingDestination), + logActionAudit(ActionsEnum.updateEventStreamingDestination), + eventStreamingDestination.updateEventStreamingDestination +); + +authenticated.delete( + "/org/:orgId/event-streaming-destination/:destinationId", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteEventStreamingDestination), + logActionAudit(ActionsEnum.deleteEventStreamingDestination), + eventStreamingDestination.deleteEventStreamingDestination +); + +authenticated.get( + "/org/:orgId/event-streaming-destinations", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listEventStreamingDestinations), + eventStreamingDestination.listEventStreamingDestinations +); diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index a38385b0c..13a6f70e0 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -52,7 +52,9 @@ import { userOrgs, roleResources, userResources, - resourceRules + resourceRules, + userOrgRoles, + roles } from "@server/db"; import { eq, and, inArray, isNotNull, ne } from "drizzle-orm"; import { response } from "@server/lib/response"; @@ -104,6 +106,13 @@ const getUserOrgSessionVerifySchema = z.strictObject({ sessionId: z.string().min(1, "Session ID is required") }); +const getRoleNameParamsSchema = z.strictObject({ + roleId: z + .string() + .transform(Number) + .pipe(z.int().positive("Role ID must be a positive integer")) +}); + const getRoleResourceAccessParamsSchema = z.strictObject({ roleId: z .string() @@ -115,6 +124,23 @@ const getRoleResourceAccessParamsSchema = z.strictObject({ .pipe(z.int().positive("Resource ID must be a positive integer")) }); +const getResourceAccessParamsSchema = z.strictObject({ + resourceId: z + .string() + .transform(Number) + .pipe(z.int().positive("Resource ID must be a positive integer")) +}); + +const getResourceAccessQuerySchema = z.strictObject({ + roleIds: z + .union([z.array(z.string()), z.string()]) + .transform((val) => + (Array.isArray(val) ? val : [val]) + .map(Number) + .filter((n) => !isNaN(n)) + ) +}); + const getUserResourceAccessParamsSchema = z.strictObject({ userId: z.string().min(1, "User ID is required"), resourceId: z @@ -760,7 +786,7 @@ hybridRouter.get( // Get user organization role hybridRouter.get( - "/user/:userId/org/:orgId/role", + "/user/:userId/org/:orgId/roles", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getUserOrgRoleParamsSchema.safeParse( @@ -796,23 +822,129 @@ hybridRouter.get( ); } - const userOrgRole = await db - .select() - .from(userOrgs) + const userOrgRoleRows = await db + .select({ roleId: userOrgRoles.roleId, roleName: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(roles.roleId, userOrgRoles.roleId)) .where( - and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) - ) - .limit(1); + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); - const result = userOrgRole.length > 0 ? userOrgRole[0] : null; + logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows); - return response(res, { - data: result, + return response<{ roleId: number, roleName: string }[]>(res, { + data: userOrgRoleRows, success: true, error: false, - message: result - ? "User org role retrieved successfully" - : "User org role not found", + message: + userOrgRoleRows.length > 0 + ? "User org roles retrieved successfully" + : "User has no roles in this organization", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get user org role" + ) + ); + } + } +); + +// DEPRICATED Get user organization role +// used for backward compatibility with old remote nodes +hybridRouter.get( + "/user/:userId/org/:orgId/role", // <- note the missing s + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getUserOrgRoleParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, orgId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "User is not authorized to access this organization" + ) + ); + } + + // get the roles on the user + + const userOrgRoleRows = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const roleIds = userOrgRoleRows.map((r) => r.roleId); + + let roleId: number | null = null; + + if (userOrgRoleRows.length === 0) { + // User has no roles in this organization + roleId = null; + } else if (userOrgRoleRows.length === 1) { + // User has exactly one role, return it + roleId = userOrgRoleRows[0].roleId; + } else { + // User has multiple roles + // Check if any of these roles are also assigned to a resource + // If we find a match, prefer that role; otherwise return the first role + // Get all resources that have any of these roles assigned + const roleResourceMatches = await db + .select({ roleId: roleResources.roleId }) + .from(roleResources) + .where(inArray(roleResources.roleId, roleIds)) + .limit(1); + if (roleResourceMatches.length > 0) { + // Return the first role that's also on a resource + roleId = roleResourceMatches[0].roleId; + } else { + // No resource match found, return the first role + roleId = userOrgRoleRows[0].roleId; + } + } + + return response<{ roleId: number | null }>(res, { + data: { roleId }, + success: true, + error: false, + message: + roleIds.length > 0 + ? "User org roles retrieved successfully" + : "User has no roles in this organization", status: HttpCode.OK }); } catch (error) { @@ -890,6 +1022,60 @@ hybridRouter.get( } ); +// Get role name by ID +hybridRouter.get( + "/role/:roleId/name", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getRoleNameParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { roleId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [role] = await db + .select({ name: roles.name }) + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + return response(res, { + data: role?.name ?? null, + success: true, + error: false, + message: role + ? "Role name retrieved successfully" + : "Role not found", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get role name" + ) + ); + } + } +); + // Check if role has access to resource hybridRouter.get( "/role/:roleId/resource/:resourceId/access", @@ -975,6 +1161,101 @@ hybridRouter.get( } ); +// Check if role has access to resource +hybridRouter.get( + "/resource/:resourceId/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getResourceAccessParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const parsedQuery = getResourceAccessQuerySchema.safeParse( + req.query + ); + const roleIds = parsedQuery.success ? parsedQuery.data.roleIds : []; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + resource.orgId + ) + ) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const roleResourceAccess = await db + .select({ + resourceId: roleResources.resourceId, + roleId: roleResources.roleId + }) + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ); + + const result = + roleResourceAccess.length > 0 ? roleResourceAccess : null; + + return response<{ resourceId: number; roleId: number }[] | null>( + res, + { + data: result, + success: true, + error: false, + message: result + ? "Role resource access retrieved successfully" + : "Role resource access not found", + status: HttpCode.OK + } + ); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get role resource access" + ) + ); + } + } +); + // Check if user has direct access to resource hybridRouter.get( "/user/:userId/resource/:resourceId/access", @@ -1873,7 +2154,8 @@ hybridRouter.post( // userAgent: data.userAgent, // TODO: add this // headers: data.body.headers, // query: data.body.query, - originalRequestURL: sanitizeString(logEntry.originalRequestURL) ?? "", + originalRequestURL: + sanitizeString(logEntry.originalRequestURL) ?? "", scheme: sanitizeString(logEntry.scheme) ?? "", host: sanitizeString(logEntry.host) ?? "", path: sanitizeString(logEntry.path) ?? "", diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 97b1adade..40bb2b56c 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -20,8 +20,11 @@ import { verifyApiKeyIsRoot, verifyApiKeyOrgAccess, verifyApiKeyIdpAccess, + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, verifyLimits } from "@server/middlewares"; +import * as user from "#private/routers/user"; import { verifyValidSubscription, verifyValidLicense @@ -91,6 +94,25 @@ authenticated.get( logs.exportAccessAuditLogs ); +authenticated.get( + "/org/:orgId/logs/connection", + verifyValidLicense, + verifyValidSubscription(tierMatrix.connectionLogs), + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.exportLogs), + logs.queryConnectionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/connection/export", + verifyValidLicense, + verifyValidSubscription(tierMatrix.logExport), + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportConnectionAuditLogs +); + authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, @@ -140,3 +162,23 @@ authenticated.get( verifyApiKeyHasAction(ActionsEnum.listIdps), orgIdp.listOrgIdps ); + +authenticated.post( + "/user/:userId/add-role/:roleId", + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.addUserRole), + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole +); + +authenticated.delete( + "/user/:userId/remove-role/:roleId", + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.removeUserRole), + logActionAudit(ActionsEnum.removeUserRole), + user.removeUserRole +); diff --git a/server/private/routers/newt/handleConnectionLogMessage.ts b/server/private/routers/newt/handleConnectionLogMessage.ts new file mode 100644 index 000000000..e980f85c9 --- /dev/null +++ b/server/private/routers/newt/handleConnectionLogMessage.ts @@ -0,0 +1,239 @@ +/* + * 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 { db } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { sites, Newt, clients, orgs } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import logger from "@server/logger"; +import { inflate } from "zlib"; +import { promisify } from "util"; +import { + logConnectionAudit, + flushConnectionLogToDb, + cleanUpOldLogs +} from "#private/lib/logConnectionAudit"; + +export { flushConnectionLogToDb, cleanUpOldLogs }; + +const zlibInflate = promisify(inflate); + +interface ConnectionSessionData { + sessionId: string; + resourceId: number; + sourceAddr: string; + destAddr: string; + protocol: string; + startedAt: string; // ISO 8601 timestamp + endedAt?: string; // ISO 8601 timestamp + bytesTx?: number; + bytesRx?: number; +} + +/** + * Decompress a base64-encoded zlib-compressed string into parsed JSON. + */ +async function decompressConnectionLog( + compressed: string +): Promise { + const compressedBuffer = Buffer.from(compressed, "base64"); + const decompressed = await zlibInflate(compressedBuffer); + const jsonString = decompressed.toString("utf-8"); + const parsed = JSON.parse(jsonString); + + if (!Array.isArray(parsed)) { + throw new Error("Decompressed connection log data is not an array"); + } + + return parsed; +} + +/** + * Convert an ISO 8601 timestamp string to epoch seconds. + * Returns null if the input is falsy. + */ +function toEpochSeconds(isoString: string | undefined | null): number | null { + if (!isoString) { + return null; + } + const ms = new Date(isoString).getTime(); + if (isNaN(ms)) { + return null; + } + return Math.floor(ms / 1000); +} + +export const handleConnectionLogMessage: MessageHandler = async (context) => { + const { message, client } = context; + const newt = client as Newt; + + if (!newt) { + logger.warn("Connection log received but no newt client in context"); + return; + } + + if (!newt.siteId) { + logger.warn("Connection log received but newt has no siteId"); + return; + } + + if (!message.data?.compressed) { + logger.warn("Connection log message missing compressed data"); + return; + } + + // Look up the org for this site + const [site] = await db + .select({ orgId: sites.orgId, orgSubnet: orgs.subnet }) + .from(sites) + .innerJoin(orgs, eq(sites.orgId, orgs.orgId)) + .where(eq(sites.siteId, newt.siteId)); + + if (!site) { + logger.warn( + `Connection log received but site ${newt.siteId} not found in database` + ); + return; + } + + const orgId = site.orgId; + + // Extract the CIDR suffix (e.g. "/16") from the org subnet so we can + // reconstruct the exact subnet string stored on each client record. + const cidrSuffix = site.orgSubnet?.includes("/") + ? site.orgSubnet.substring(site.orgSubnet.indexOf("/")) + : null; + + let sessions: ConnectionSessionData[]; + try { + sessions = await decompressConnectionLog(message.data.compressed); + } catch (error) { + logger.error("Failed to decompress connection log data:", error); + return; + } + + if (sessions.length === 0) { + return; + } + + logger.debug(`Sessions: ${JSON.stringify(sessions)}`); + + // Build a map from sourceAddr → { clientId, userId } by querying clients + // whose subnet field matches exactly. Client subnets are stored with the + // org's CIDR suffix (e.g. "100.90.128.5/16"), so we reconstruct that from + // each unique sourceAddr + the org's CIDR suffix and do a targeted IN query. + const ipToClient = new Map< + string, + { clientId: number; userId: string | null } + >(); + + if (cidrSuffix) { + // Collect unique source addresses so we only query for what we need + const uniqueSourceAddrs = new Set(); + for (const session of sessions) { + if (session.sourceAddr) { + uniqueSourceAddrs.add(session.sourceAddr); + } + } + + if (uniqueSourceAddrs.size > 0) { + // Construct the exact subnet strings as stored in the DB + const subnetQueries = Array.from(uniqueSourceAddrs).map((addr) => { + // Strip port if present (e.g. "100.90.128.1:38004" → "100.90.128.1") + const ip = addr.includes(":") ? addr.split(":")[0] : addr; + return `${ip}${cidrSuffix}`; + }); + + logger.debug(`Subnet queries: ${JSON.stringify(subnetQueries)}`); + + const matchedClients = await db + .select({ + clientId: clients.clientId, + userId: clients.userId, + subnet: clients.subnet + }) + .from(clients) + .where( + and( + eq(clients.orgId, orgId), + inArray(clients.subnet, subnetQueries) + ) + ); + + for (const c of matchedClients) { + const ip = c.subnet.split("/")[0]; + logger.debug( + `Client ${c.clientId} subnet ${c.subnet} matches ${ip}` + ); + ipToClient.set(ip, { + clientId: c.clientId, + userId: c.userId + }); + } + } + } + + // Convert to DB records and hand off to the audit logger + for (const session of sessions) { + // Validate required fields + if ( + !session.sessionId || + !session.resourceId || + !session.sourceAddr || + !session.destAddr || + !session.protocol + ) { + logger.debug( + `Skipping connection log session with missing required fields: ${JSON.stringify(session)}` + ); + continue; + } + + const startedAt = toEpochSeconds(session.startedAt); + if (startedAt === null) { + logger.debug( + `Skipping connection log session with invalid startedAt: ${session.startedAt}` + ); + continue; + } + + // Match the source address to a client. The sourceAddr is the + // client's IP on the WireGuard network, which corresponds to the IP + // portion of the client's subnet CIDR (e.g. "100.90.128.5/24"). + // Strip port if present (e.g. "100.90.128.1:38004" → "100.90.128.1") + const sourceIp = session.sourceAddr.includes(":") + ? session.sourceAddr.split(":")[0] + : session.sourceAddr; + const clientInfo = ipToClient.get(sourceIp) ?? null; + + logConnectionAudit({ + sessionId: session.sessionId, + siteResourceId: session.resourceId, + orgId, + siteId: newt.siteId, + clientId: clientInfo?.clientId ?? null, + userId: clientInfo?.userId ?? null, + sourceAddr: session.sourceAddr, + destAddr: session.destAddr, + protocol: session.protocol, + startedAt, + endedAt: toEpochSeconds(session.endedAt), + bytesTx: session.bytesTx ?? null, + bytesRx: session.bytesRx ?? null + }); + } + + logger.debug( + `Buffered ${sessions.length} connection log session(s) from newt ${newt.newtId} (site ${newt.siteId})` + ); +}; diff --git a/server/private/routers/newt/index.ts b/server/private/routers/newt/index.ts new file mode 100644 index 000000000..256d19cb7 --- /dev/null +++ b/server/private/routers/newt/index.ts @@ -0,0 +1,14 @@ +/* + * 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 "./handleConnectionLogMessage"; diff --git a/server/private/routers/org/sendUsageNotifications.ts b/server/private/routers/org/sendUsageNotifications.ts index 4aa421520..72fc00d4c 100644 --- a/server/private/routers/org/sendUsageNotifications.ts +++ b/server/private/routers/org/sendUsageNotifications.ts @@ -14,7 +14,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userOrgs, users, roles, orgs } from "@server/db"; +import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db"; import { eq, and, or } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -95,7 +95,14 @@ async function getOrgAdmins(orgId: string) { }) .from(userOrgs) .innerJoin(users, eq(userOrgs.userId, users.userId)) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) + .leftJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where( and( eq(userOrgs.orgId, orgId), @@ -103,8 +110,11 @@ async function getOrgAdmins(orgId: string) { ) ); - // Filter to only include users with verified emails - const orgAdmins = admins.filter( + // Dedupe by userId (user may have multiple roles) + const byUserId = new Map( + admins.map((a) => [a.userId, a]) + ); + const orgAdmins = Array.from(byUserId.values()).filter( (admin) => admin.email && admin.email.length > 0 ); diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index 6d5b5ea6f..f24afdde1 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -79,7 +79,7 @@ export async function createRemoteExitNode( const { remoteExitNodeId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts new file mode 100644 index 000000000..e521eaa22 --- /dev/null +++ b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts @@ -0,0 +1,149 @@ +/* + * 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 { NextFunction, Request, Response } from "express"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import moment from "moment"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; +import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; + +const paramsSchema = z.object({ + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + name: z.string().min(1).max(255), + maxBatchSize: z.union([ + z.null(), + z.coerce.number().int().positive().max(1_000_000) + ]), + validUntil: z.string().max(255).optional(), + approveNewSites: z.boolean().optional().default(true) + }) + .superRefine((data, ctx) => { + const v = data.validUntil; + if (v == null || v.trim() === "") { + return; + } + if (Number.isNaN(Date.parse(v))) { + ctx.addIssue({ + code: "custom", + message: "Invalid validUntil", + path: ["validUntil"] + }); + } + }); + +export type CreateSiteProvisioningKeyBody = z.infer; + +export async function createSiteProvisioningKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { name, maxBatchSize, approveNewSites } = parsedBody.data; + const vuRaw = parsedBody.data.validUntil; + const validUntil = + vuRaw == null || vuRaw.trim() === "" + ? null + : new Date(Date.parse(vuRaw)).toISOString(); + + const siteProvisioningKeyId = `spk-${generateId(15)}`; + const siteProvisioningKey = generateIdFromEntropySize(25); + const siteProvisioningKeyHash = await hashPassword(siteProvisioningKey); + const lastChars = siteProvisioningKey.slice(-4); + const createdAt = moment().toISOString(); + const provisioningKey = `${siteProvisioningKeyId}.${siteProvisioningKey}`; + + await db.transaction(async (trx) => { + await trx.insert(siteProvisioningKeys).values({ + siteProvisioningKeyId, + name, + siteProvisioningKeyHash, + createdAt, + lastChars, + lastUsed: null, + maxBatchSize, + numUsed: 0, + validUntil, + approveNewSites + }); + + await trx.insert(siteProvisioningKeyOrg).values({ + siteProvisioningKeyId, + orgId + }); + }); + + try { + return response(res, { + data: { + siteProvisioningKeyId, + orgId, + name, + siteProvisioningKey: provisioningKey, + lastChars, + createdAt, + lastUsed: null, + maxBatchSize, + numUsed: 0, + validUntil, + approveNewSites + }, + success: true, + error: false, + message: "Site provisioning key created", + status: HttpCode.CREATED + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create site provisioning key" + ) + ); + } +} diff --git a/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts new file mode 100644 index 000000000..fc8b05e60 --- /dev/null +++ b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts @@ -0,0 +1,129 @@ +/* + * 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 { + db, + siteProvisioningKeyOrg, + siteProvisioningKeys +} from "@server/db"; +import { and, eq } 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"; + +const paramsSchema = z.object({ + siteProvisioningKeyId: z.string().nonempty(), + orgId: z.string().nonempty() +}); + +export async function deleteSiteProvisioningKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteProvisioningKeyId, orgId } = parsedParams.data; + + const [row] = await db + .select() + .from(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .innerJoin( + siteProvisioningKeyOrg, + and( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (!row) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site provisioning key with ID ${siteProvisioningKeyId} not found` + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(siteProvisioningKeyOrg) + .where( + and( + eq( + siteProvisioningKeyOrg.siteProvisioningKeyId, + siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ); + + const siteProvisioningKeyOrgs = await trx + .select() + .from(siteProvisioningKeyOrg) + .where( + eq( + siteProvisioningKeyOrg.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + + if (siteProvisioningKeyOrgs.length === 0) { + await trx + .delete(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Site provisioning key deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/siteProvisioning/index.ts b/server/private/routers/siteProvisioning/index.ts new file mode 100644 index 000000000..d143274f6 --- /dev/null +++ b/server/private/routers/siteProvisioning/index.ts @@ -0,0 +1,17 @@ +/* + * 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 "./createSiteProvisioningKey"; +export * from "./listSiteProvisioningKeys"; +export * from "./deleteSiteProvisioningKey"; +export * from "./updateSiteProvisioningKey"; diff --git a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts new file mode 100644 index 000000000..dd51179d3 --- /dev/null +++ b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts @@ -0,0 +1,127 @@ +/* + * 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 { + db, + siteProvisioningKeyOrg, + siteProvisioningKeys +} from "@server/db"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; +import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; + +const paramsSchema = z.object({ + orgId: z.string().nonempty() +}); + +const querySchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +function querySiteProvisioningKeys(orgId: string) { + return db + .select({ + siteProvisioningKeyId: + siteProvisioningKeys.siteProvisioningKeyId, + orgId: siteProvisioningKeyOrg.orgId, + lastChars: siteProvisioningKeys.lastChars, + createdAt: siteProvisioningKeys.createdAt, + name: siteProvisioningKeys.name, + lastUsed: siteProvisioningKeys.lastUsed, + maxBatchSize: siteProvisioningKeys.maxBatchSize, + numUsed: siteProvisioningKeys.numUsed, + validUntil: siteProvisioningKeys.validUntil, + approveNewSites: siteProvisioningKeys.approveNewSites + }) + .from(siteProvisioningKeyOrg) + .innerJoin( + siteProvisioningKeys, + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ) + ) + .where(eq(siteProvisioningKeyOrg.orgId, orgId)); +} + +export async function listSiteProvisioningKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const { orgId } = parsedParams.data; + const { limit, offset } = parsedQuery.data; + + const siteProvisioningKeysList = await querySiteProvisioningKeys(orgId) + .limit(limit) + .offset(offset); + + return response(res, { + data: { + siteProvisioningKeys: siteProvisioningKeysList, + pagination: { + total: siteProvisioningKeysList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "Site provisioning keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts new file mode 100644 index 000000000..2f4dafbdf --- /dev/null +++ b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts @@ -0,0 +1,206 @@ +/* + * 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 { + db, + siteProvisioningKeyOrg, + siteProvisioningKeys +} from "@server/db"; +import { and, eq } 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 type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; + +const paramsSchema = z.object({ + siteProvisioningKeyId: z.string().nonempty(), + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + maxBatchSize: z + .union([ + z.null(), + z.coerce.number().int().positive().max(1_000_000) + ]) + .optional(), + validUntil: z.string().max(255).optional(), + approveNewSites: z.boolean().optional() + }) + .superRefine((data, ctx) => { + if ( + data.maxBatchSize === undefined && + data.validUntil === undefined && + data.approveNewSites === undefined + ) { + ctx.addIssue({ + code: "custom", + message: "Provide maxBatchSize and/or validUntil and/or approveNewSites", + path: ["maxBatchSize"] + }); + } + const v = data.validUntil; + if (v == null || v.trim() === "") { + return; + } + if (Number.isNaN(Date.parse(v))) { + ctx.addIssue({ + code: "custom", + message: "Invalid validUntil", + path: ["validUntil"] + }); + } + }); + +export type UpdateSiteProvisioningKeyBody = z.infer; + +export async function updateSiteProvisioningKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { siteProvisioningKeyId, orgId } = parsedParams.data; + const body = parsedBody.data; + + const [row] = await db + .select() + .from(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .innerJoin( + siteProvisioningKeyOrg, + and( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (!row) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site provisioning key with ID ${siteProvisioningKeyId} not found` + ) + ); + } + + const setValues: { + maxBatchSize?: number | null; + validUntil?: string | null; + approveNewSites?: boolean; + } = {}; + if (body.maxBatchSize !== undefined) { + setValues.maxBatchSize = body.maxBatchSize; + } + if (body.validUntil !== undefined) { + setValues.validUntil = + body.validUntil.trim() === "" + ? null + : new Date(Date.parse(body.validUntil)).toISOString(); + } + if (body.approveNewSites !== undefined) { + setValues.approveNewSites = body.approveNewSites; + } + + await db + .update(siteProvisioningKeys) + .set(setValues) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + + const [updated] = await db + .select({ + siteProvisioningKeyId: + siteProvisioningKeys.siteProvisioningKeyId, + name: siteProvisioningKeys.name, + lastChars: siteProvisioningKeys.lastChars, + createdAt: siteProvisioningKeys.createdAt, + lastUsed: siteProvisioningKeys.lastUsed, + maxBatchSize: siteProvisioningKeys.maxBatchSize, + numUsed: siteProvisioningKeys.numUsed, + validUntil: siteProvisioningKeys.validUntil, + approveNewSites: siteProvisioningKeys.approveNewSites + }) + .from(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .limit(1); + + if (!updated) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to load updated site provisioning key" + ) + ); + } + + return response(res, { + data: { + ...updated, + orgId + }, + success: true, + error: false, + message: "Site provisioning key updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 5cffb4a34..b02d2b23c 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -24,6 +24,7 @@ import { sites, userOrgs } from "@server/db"; +import { logAccessAudit } from "#private/lib/logAccessAudit"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import response from "@server/lib/response"; @@ -31,7 +32,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { eq, or, and } from "drizzle-orm"; +import { and, eq, inArray, or } from "drizzle-orm"; import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA"; import config from "@server/lib/config"; @@ -125,7 +126,7 @@ export async function signSshKey( resource: resourceQueryString } = parsedBody.data; const userId = req.user?.userId; - const roleId = req.userOrgRoleId!; + const roleIds = req.userOrgRoleIds ?? []; if (!userId) { return next( @@ -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 .select() .from(userOrgs) @@ -339,7 +349,7 @@ export async function signSshKey( const hasAccess = await canUserAccessSiteResource({ userId: userId, resourceId: resource.siteResourceId, - roleId: roleId + roleIds }); if (!hasAccess) { @@ -351,28 +361,39 @@ export async function signSshKey( ); } - const [roleRow] = await db + const roleRows = await db .select() .from(roles) - .where(eq(roles.roleId, roleId)) - .limit(1); + .where(inArray(roles.roleId, roleIds)); - let parsedSudoCommands: string[] = []; - let parsedGroups: string[] = []; - try { - parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); - if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = []; - } catch { - parsedSudoCommands = []; + const parsedSudoCommands: string[] = []; + const parsedGroupsSet = new Set(); + let homedir: boolean | null = null; + const sudoModeOrder = { none: 0, commands: 1, all: 2 }; + let sudoMode: "none" | "commands" | "all" = "none"; + for (const roleRow of roleRows) { + try { + const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); + if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds); + } catch { + // skip + } + try { + const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); + if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g)); + } catch { + // skip + } + if (roleRow?.sshCreateHomeDir === true) homedir = true; + const m = roleRow?.sshSudoMode ?? "none"; + if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) { + sudoMode = m as "none" | "commands" | "all"; + } } - try { - parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); - if (!Array.isArray(parsedGroups)) parsedGroups = []; - } catch { - parsedGroups = []; + const parsedGroups = Array.from(parsedGroupsSet); + if (homedir === null && roleRows.length > 0) { + homedir = roleRows[0].sshCreateHomeDir ?? null; } - const homedir = roleRow?.sshCreateHomeDir ?? null; - const sudoMode = roleRow?.sshSudoMode ?? "none"; // get the site const [newt] = await db @@ -463,6 +484,24 @@ export async function signSshKey( }) }); + await logAccessAudit({ + action: true, + type: "ssh", + orgId: orgId, + siteResourceId: 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(res, { data: { certificate: cert.certificate, diff --git a/server/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts similarity index 78% rename from server/routers/user/addUserRole.ts rename to server/private/routers/user/addUserRole.ts index 32eaa19d7..a46bd1ed8 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -1,14 +1,27 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { clients, db, UserOrg } from "@server/db"; -import { userOrgs, roles } from "@server/db"; +import stoi from "@server/lib/stoi"; +import { clients, db } from "@server/db"; +import { userOrgRoles, userOrgs, roles } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; @@ -17,11 +30,9 @@ const addUserRoleParamsSchema = z.strictObject({ roleId: z.string().transform(stoi).pipe(z.number()) }); -export type AddUserRoleResponse = z.infer; - registry.registerPath({ method: "post", - path: "/role/{roleId}/add/{userId}", + path: "/user/{userId}/add-role/{roleId}", description: "Add a role to a user.", tags: [OpenAPITags.Role, OpenAPITags.User], request: { @@ -111,20 +122,23 @@ export async function addUserRole( ); } - let newUserRole: UserOrg | null = null; + let newUserRole: { userId: string; orgId: string; roleId: number } | null = + null; await db.transaction(async (trx) => { - [newUserRole] = await trx - .update(userOrgs) - .set({ roleId }) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, role.orgId) - ) - ) + const inserted = await trx + .insert(userOrgRoles) + .values({ + userId, + orgId: role.orgId, + roleId + }) + .onConflictDoNothing() .returning(); - // get the client associated with this user in this org + if (inserted.length > 0) { + newUserRole = inserted[0]; + } + const orgClients = await trx .select() .from(clients) @@ -133,17 +147,15 @@ export async function addUserRole( eq(clients.userId, userId), eq(clients.orgId, role.orgId) ) - ) - .limit(1); + ); for (const orgClient of orgClients) { - // we just changed the user's role, so we need to rebuild client associations and what they have access to await rebuildClientAssociationsFromClient(orgClient, trx); } }); return response(res, { - data: newUserRole, + data: newUserRole ?? { userId, orgId: role.orgId, roleId }, success: true, error: false, message: "Role added to user successfully", diff --git a/server/private/routers/user/index.ts b/server/private/routers/user/index.ts new file mode 100644 index 000000000..6317eced5 --- /dev/null +++ b/server/private/routers/user/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./addUserRole"; +export * from "./removeUserRole"; +export * from "./setUserOrgRoles"; diff --git a/server/private/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts new file mode 100644 index 000000000..e9c3d10c0 --- /dev/null +++ b/server/private/routers/user/removeUserRole.ts @@ -0,0 +1,171 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import stoi from "@server/lib/stoi"; +import { db } from "@server/db"; +import { userOrgRoles, userOrgs, roles, clients } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; + +const removeUserRoleParamsSchema = z.strictObject({ + userId: z.string(), + roleId: z.string().transform(stoi).pipe(z.number()) +}); + +registry.registerPath({ + method: "delete", + path: "/user/{userId}/remove-role/{roleId}", + description: + "Remove a role from a user. User must have at least one role left in the org.", + tags: [OpenAPITags.Role, OpenAPITags.User], + request: { + params: removeUserRoleParamsSchema + }, + responses: {} +}); + +export async function removeUserRole( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = removeUserRoleParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, roleId } = parsedParams.data; + + if (req.user && !req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + if (!role) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") + ); + } + + const [existingUser] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)) + ) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found or does not belong to the specified organization" + ) + ); + } + + if (existingUser.isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot change the roles of the owner of the organization" + ) + ); + } + + const remainingRoles = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId) + ) + ); + + if (remainingRoles.length <= 1) { + const hasThisRole = remainingRoles.some((r) => r.roleId === roleId); + if (hasThisRole) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User must have at least one role in the organization. Remove the last role is not allowed." + ) + ); + } + } + + await db.transaction(async (trx) => { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId), + eq(userOrgRoles.roleId, roleId) + ) + ); + + const orgClients = await trx + .select() + .from(clients) + .where( + and( + eq(clients.userId, userId), + eq(clients.orgId, role.orgId) + ) + ); + + for (const orgClient of orgClients) { + await rebuildClientAssociationsFromClient(orgClient, trx); + } + }); + + return response(res, { + data: { userId, orgId: role.orgId, roleId }, + success: true, + error: false, + message: "Role removed from user successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts new file mode 100644 index 000000000..67563fd26 --- /dev/null +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -0,0 +1,163 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { clients, db } from "@server/db"; +import { userOrgRoles, userOrgs, roles } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; + +const setUserOrgRolesParamsSchema = z.strictObject({ + orgId: z.string(), + userId: z.string() +}); + +const setUserOrgRolesBodySchema = z.strictObject({ + roleIds: z.array(z.int().positive()).min(1) +}); + +export async function setUserOrgRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setUserOrgRolesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = setUserOrgRolesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, userId } = parsedParams.data; + const { roleIds } = parsedBody.data; + + if (req.user && !req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const uniqueRoleIds = [...new Set(roleIds)]; + + const [existingUser] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in this organization" + ) + ); + } + + if (existingUser.isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot change the roles of the owner of the organization" + ) + ); + } + + const orgRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + inArray(roles.roleId, uniqueRoleIds) + ) + ); + + if (orgRoles.length !== uniqueRoleIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more role IDs are invalid for this organization" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + if (uniqueRoleIds.length > 0) { + await trx.insert(userOrgRoles).values( + uniqueRoleIds.map((roleId) => ({ + userId, + orgId, + roleId + })) + ); + } + + const orgClients = await trx + .select() + .from(clients) + .where( + and(eq(clients.userId, userId), eq(clients.orgId, orgId)) + ); + + for (const orgClient of orgClients) { + await rebuildClientAssociationsFromClient(orgClient, trx); + } + }); + + return response(res, { + data: { userId, orgId, roleIds: uniqueRoleIds }, + success: true, + error: false, + message: "User roles set successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/ws/messageHandlers.ts b/server/private/routers/ws/messageHandlers.ts index d388ce40a..5021cb966 100644 --- a/server/private/routers/ws/messageHandlers.ts +++ b/server/private/routers/ws/messageHandlers.ts @@ -18,10 +18,12 @@ import { } from "#private/routers/remoteExitNode"; import { MessageHandler } from "@server/routers/ws"; import { build } from "@server/build"; +import { handleConnectionLogMessage } from "#private/routers/newt"; export const messageHandlers: Record = { "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, - "remoteExitNode/ping": handleRemoteExitNodePingMessage + "remoteExitNode/ping": handleRemoteExitNodePingMessage, + "newt/access-log": handleConnectionLogMessage, }; if (build != "saas") { diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 9f8747190..55751df81 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -208,7 +208,7 @@ export async function listAccessTokens( .where( or( eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + inArray(roleResources.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 474aa9261..4c278cba5 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -91,3 +91,50 @@ export type QueryAccessAuditLogResponse = { locations: string[]; }; }; + +export type QueryConnectionAuditLogResponse = { + log: { + sessionId: string; + siteResourceId: number | null; + orgId: string | null; + siteId: number | null; + clientId: number | null; + userId: string | null; + sourceAddr: string; + destAddr: string; + protocol: string; + startedAt: number; + endedAt: number | null; + bytesTx: number | null; + bytesRx: number | null; + resourceName: string | null; + resourceNiceId: string | null; + siteName: string | null; + siteNiceId: string | null; + clientName: string | null; + clientNiceId: string | null; + clientType: string | null; + userEmail: string | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + protocols: string[]; + destAddrs: string[]; + clients: { + id: number; + name: string; + }[]; + resources: { + id: number; + name: string | null; + }[]; + users: { + id: string; + email: string | null; + }[]; + }; +}; diff --git a/server/routers/badger/verifySession.test.ts b/server/routers/badger/verifySession.test.ts index 7c967acef..8333a4578 100644 --- a/server/routers/badger/verifySession.test.ts +++ b/server/routers/badger/verifySession.test.ts @@ -1,4 +1,37 @@ import { assertEquals } from "@test/assert"; +import { REGIONS } from "@server/db/regions"; + +function isIpInRegion( + ipCountryCode: string | undefined, + checkRegionCode: string +): boolean { + if (!ipCountryCode) { + return false; + } + + const upperCode = ipCountryCode.toUpperCase(); + + for (const region of REGIONS) { + // Check if it's a top-level region (continent) + if (region.id === checkRegionCode) { + for (const subregion of region.includes) { + if (subregion.countries.includes(upperCode)) { + return true; + } + } + return false; + } + + // Check subregions + for (const subregion of region.includes) { + if (subregion.id === checkRegionCode) { + return subregion.countries.includes(upperCode); + } + } + } + + return false; +} function isPathAllowed(pattern: string, path: string): boolean { // Normalize and split paths into segments @@ -272,12 +305,71 @@ function runTests() { "Root path should not match non-root path" ); - console.log("All tests passed!"); + console.log("All path matching tests passed!"); +} + +function runRegionTests() { + console.log("\nRunning isIpInRegion tests..."); + + // Test undefined country code + assertEquals( + isIpInRegion(undefined, "150"), + false, + "Undefined country code should return false" + ); + + // Test subregion matching (Western Europe) + assertEquals( + isIpInRegion("DE", "155"), + true, + "Country should match its subregion" + ); + assertEquals( + isIpInRegion("GB", "155"), + false, + "Country should NOT match wrong subregion" + ); + + // Test continent matching (Europe) + assertEquals( + isIpInRegion("DE", "150"), + true, + "Country should match its continent" + ); + assertEquals( + isIpInRegion("GB", "150"), + true, + "Different European country should match Europe" + ); + assertEquals( + isIpInRegion("US", "150"), + false, + "Non-European country should NOT match Europe" + ); + + // Test case insensitivity + assertEquals( + isIpInRegion("de", "155"), + true, + "Lowercase country code should work" + ); + + // Test invalid region code + assertEquals( + isIpInRegion("DE", "999"), + false, + "Invalid region code should return false" + ); + + console.log("All region tests passed!"); } // Run all tests try { runTests(); + runRegionTests(); + console.log("\n✅ All tests passed!"); } catch (error) { - console.error("Test failed:", error); + console.error("❌ Test failed:", error); + process.exit(1); } diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index e99052cd7..e2e5f6766 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -4,11 +4,11 @@ import { getResourceByDomain, getResourceRules, getRoleResourceAccess, - getUserOrgRole, getUserResourceAccess, getOrgLoginPage, getUserSessionWithUser } from "@server/db/queries/verifySessionQueries"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import { LoginPage, Org, @@ -30,13 +30,13 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; import { getAsnForIp } from "@server/lib/asn"; -import { getOrgTierData } from "#dynamic/lib/billing"; import { verifyPassword } from "@server/auth/password"; import { checkOrgAccessPolicy, enforceResourceSessionLength } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; +import { REGIONS } from "@server/db/regions"; import { localCache } from "#dynamic/lib/cache"; import { APP_VERSION } from "@server/lib/consts"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; @@ -797,7 +797,8 @@ async function notAllowed( ) { let loginPage: LoginPage | null = null; if (orgId) { - const subscribed = await isSubscribed( // this is fine because the org login page is only a saas feature + const subscribed = await isSubscribed( + // this is fine because the org login page is only a saas feature orgId, tierMatrix.loginPageDomain ); @@ -854,7 +855,10 @@ async function headerAuthChallenged( ) { let loginPage: LoginPage | null = null; if (orgId) { - const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain); // this is fine because the org login page is only a saas feature + const subscribed = await isSubscribed( + orgId, + tierMatrix.loginPageDomain + ); // this is fine because the org login page is only a saas feature if (subscribed) { loginPage = await getOrgLoginPage(orgId); } @@ -916,9 +920,9 @@ async function isUserAllowedToAccessResource( return null; } - const userOrgRole = await getUserOrgRole(user.userId, resource.orgId); + const userOrgRoles = await getUserOrgRoles(user.userId, resource.orgId); - if (!userOrgRole) { + if (!userOrgRoles.length) { return null; } @@ -936,15 +940,14 @@ async function isUserAllowedToAccessResource( const roleResourceAccess = await getRoleResourceAccess( resource.resourceId, - userOrgRole.roleId + userOrgRoles.map((r) => r.roleId) ); - - if (roleResourceAccess) { + if (roleResourceAccess && roleResourceAccess.length > 0) { return { username: user.username, email: user.email, name: user.name, - role: userOrgRole.roleName + role: userOrgRoles.map((r) => r.roleName).join(", ") }; } @@ -958,7 +961,7 @@ async function isUserAllowedToAccessResource( username: user.username, email: user.email, name: user.name, - role: userOrgRole.roleName + role: userOrgRoles.map((r) => r.roleName).join(", ") }; } @@ -1020,6 +1023,12 @@ async function checkRules( (await isIpInAsn(ipAsn, rule.value)) ) { return rule.action as any; + } else if ( + clientIp && + rule.match == "REGION" && + (await isIpInRegion(ipCC, rule.value)) + ) { + return rule.action as any; } } @@ -1205,6 +1214,45 @@ async function isIpInAsn( return match; } +export async function isIpInRegion( + ipCountryCode: string | undefined, + checkRegionCode: string +): Promise { + if (!ipCountryCode) { + return false; + } + + const upperCode = ipCountryCode.toUpperCase(); + + for (const region of REGIONS) { + // Check if it's a top-level region (continent) + if (region.id === checkRegionCode) { + for (const subregion of region.includes) { + if (subregion.countries.includes(upperCode)) { + logger.debug(`Country ${upperCode} is in region ${region.id} (${region.name})`); + return true; + } + } + logger.debug(`Country ${upperCode} is not in region ${region.id} (${region.name})`); + return false; + } + + // Check subregions + for (const subregion of region.includes) { + if (subregion.id === checkRegionCode) { + if (subregion.countries.includes(upperCode)) { + logger.debug(`Country ${upperCode} is in region ${subregion.id} (${subregion.name})`); + return true; + } + logger.debug(`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`); + return false; + } + } + } + + return false; +} + async function getAsnFromIp(ip: string): Promise { const asnCacheKey = `asn:${ip}`; diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index 6f26d8cf3..337d7e714 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -92,7 +92,7 @@ export async function createClient( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -234,7 +234,7 @@ export async function createClient( clientId: newClient.clientId }); - if (req.user && req.userOrgRoleId != adminRole.roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole.roleId)) { // make sure the user can access the client trx.insert(userClients).values({ userId: req.user.userId, diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 3b7adf2d5..0bf798509 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -297,7 +297,7 @@ export async function listClients( .where( or( eq(userClients.userId, req.user!.userId), - eq(roleClients.roleId, req.userOrgRoleId!) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index e99760b91..0ae31165a 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -316,7 +316,7 @@ export async function listUserDevices( .where( or( eq(userClients.userId, req.user!.userId), - eq(roleClients.roleId, req.userOrgRoleId!) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index 94d41a4d1..b2d49db4c 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -1,15 +1,54 @@ 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 { Alias, SubnetProxyTarget } from "@server/lib/ip"; import logger from "@server/logger"; 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( newtId: string, - targets: SubnetProxyTarget[], + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], version?: string | null ) { + targets = await convertTargetsIfNessicary(newtId, targets); + await sendToClient( newtId, { @@ -22,9 +61,11 @@ export async function addTargets( export async function removeTargets( newtId: string, - targets: SubnetProxyTarget[], + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], version?: string | null ) { + targets = await convertTargetsIfNessicary(newtId, targets); + await sendToClient( newtId, { @@ -38,11 +79,39 @@ export async function removeTargets( export async function updateTargets( newtId: string, targets: { - oldTargets: SubnetProxyTarget[]; - newTargets: SubnetProxyTarget[]; + oldTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[]; + newTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[]; }, 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( newtId, { diff --git a/server/routers/external.ts b/server/routers/external.ts index 334678944..8914a0251 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -102,6 +102,8 @@ authenticated.put( logActionAudit(ActionsEnum.createSite), site.createSite ); + + authenticated.get( "/org/:orgId/sites", verifyOrgAccess, @@ -644,6 +646,7 @@ authenticated.delete( logActionAudit(ActionsEnum.deleteRole), role.deleteRole ); + authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, @@ -651,7 +654,7 @@ authenticated.post( verifyLimits, verifyUserHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole + user.addUserRoleLegacy ); authenticated.post( @@ -1207,6 +1210,22 @@ authRouter.post( }), newt.getNewtToken ); + +authRouter.post( + "/newt/register", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 30, + keyGenerator: (req) => + `newtRegister:${req.body.provisioningKey?.split(".")[0] || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only register a newt ${30} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + newt.registerNewt +); authRouter.post( "/olm/get-token", rateLimit({ diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index a1159bc22..b4cce8c4e 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { sql } from "drizzle-orm"; -import { db } from "@server/db"; +import { db, DB_TYPE } from "@server/db"; import logger from "@server/logger"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -96,6 +96,10 @@ async function dbQueryRows>( return (await anyDb.all(query)) as T[]; } +function isSQLite(): boolean { + return DB_TYPE == "sqlite"; +} + /** * Flush all accumulated site bandwidth data to the database. * @@ -141,19 +145,37 @@ export async function flushSiteBandwidthToDb(): Promise { 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}::text, ${bytesIn}::real, ${bytesOut}::real)` - ); - const valuesClause = sql.join(valuesList, sql`, `); - let rows: { orgId: string; pubKey: string }[] = []; try { rows = await withDeadlockRetry(async () => { + if (isSQLite()) { + // SQLite: one UPDATE per row — no need for batch efficiency here. + const results: { orgId: string; pubKey: string }[] = []; + for (const [publicKey, { bytesIn, bytesOut }] of chunk) { + const result = await dbQueryRows<{ + orgId: string; + pubKey: string; + }>(sql` + UPDATE sites + SET + "bytesOut" = COALESCE("bytesOut", 0) + ${bytesIn}, + "bytesIn" = COALESCE("bytesIn", 0) + ${bytesOut}, + "lastBandwidthUpdate" = ${currentTime} + WHERE "pubKey" = ${publicKey} + RETURNING "orgId", "pubKey" + `); + results.push(...result); + } + return results; + } + + // PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk. + const valuesList = chunk.map( + ([publicKey, { bytesIn, bytesOut }]) => + sql`(${publicKey}, ${bytesIn}, ${bytesOut})` + ); + const valuesClause = sql.join(valuesList, sql`, `); return dbQueryRows<{ orgId: string; pubKey: string }>(sql` UPDATE sites SET diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 5b53f6820..0f0cc0cce 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -25,7 +25,8 @@ const bodySchema = z.strictObject({ namePath: z.string().optional(), scopes: z.string().nonempty(), autoProvision: z.boolean().optional(), - tags: z.string().optional() + tags: z.string().optional(), + variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc") }); export type CreateIdpResponse = { @@ -77,7 +78,8 @@ export async function createOidcIdp( namePath, name, autoProvision, - tags + tags, + variant } = parsedBody.data; if ( @@ -121,7 +123,8 @@ export async function createOidcIdp( scopes, identifierPath, emailPath, - namePath + namePath, + variant }); }); diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index fe32a8b08..905b32013 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -31,7 +31,8 @@ const bodySchema = z.strictObject({ autoProvision: z.boolean().optional(), defaultRoleMapping: z.string().optional(), defaultOrgMapping: z.string().optional(), - tags: z.string().optional() + tags: z.string().optional(), + variant: z.enum(["oidc", "google", "azure"]).optional() }); export type UpdateIdpResponse = { @@ -96,7 +97,8 @@ export async function updateOidcIdp( autoProvision, defaultRoleMapping, defaultOrgMapping, - tags + tags, + variant } = parsedBody.data; if (process.env.IDENTITY_PROVIDER_MODE === "org") { @@ -159,7 +161,8 @@ export async function updateOidcIdp( scopes, identifierPath, emailPath, - namePath + namePath, + variant }; keysToUpdate = Object.keys(configData).filter( diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index e34621856..7c9e53cf2 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -13,6 +13,7 @@ import { orgs, Role, roles, + userOrgRoles, userOrgs, users } from "@server/db"; @@ -35,11 +36,13 @@ import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { assignUserToOrg, removeUserFromOrg } from "@server/lib/userOrg"; +import { unwrapRoleMapping } from "@app/lib/idpRoleMapping"; const ensureTrailingSlash = (url: string): string => { return url; @@ -366,7 +369,7 @@ export async function validateOidcCallback( const defaultRoleMapping = existingIdp.idp.defaultRoleMapping; const defaultOrgMapping = existingIdp.idp.defaultOrgMapping; - const userOrgInfo: { orgId: string; roleId: number }[] = []; + const userOrgInfo: { orgId: string; roleIds: number[] }[] = []; for (const org of allOrgs) { const [idpOrgRes] = await db .select() @@ -378,8 +381,6 @@ export async function validateOidcCallback( ) ); - let roleId: number | undefined = undefined; - const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; const hydratedOrgMapping = hydrateOrgMapping( orgMapping, @@ -404,38 +405,55 @@ export async function validateOidcCallback( idpOrgRes?.roleMapping || defaultRoleMapping; if (roleMapping) { logger.debug("Role Mapping", { roleMapping }); - const roleName = jmespath.search(claims, roleMapping); + const roleMappingJmes = unwrapRoleMapping( + roleMapping + ).evaluationExpression; + const roleMappingResult = jmespath.search( + claims, + roleMappingJmes + ); + const roleNames = normalizeRoleMappingResult( + roleMappingResult + ); - if (!roleName) { - logger.error("Role name not found in the ID token", { - roleName + const supportsMultiRole = await isLicensedOrSubscribed( + org.orgId, + tierMatrix.fullRbac + ); + const effectiveRoleNames = supportsMultiRole + ? roleNames + : roleNames.slice(0, 1); + + if (!effectiveRoleNames.length) { + logger.error("Role mapping returned no valid roles", { + roleMappingResult }); continue; } - const [roleRes] = await db + const roleRes = await db .select() .from(roles) .where( and( eq(roles.orgId, org.orgId), - eq(roles.name, roleName) + inArray(roles.name, effectiveRoleNames) ) ); - if (!roleRes) { - logger.error("Role not found", { + if (!roleRes.length) { + logger.error("No mapped roles found in organization", { orgId: org.orgId, - roleName + roleNames: effectiveRoleNames }); continue; } - roleId = roleRes.roleId; + const roleIds = [...new Set(roleRes.map((r) => r.roleId))]; userOrgInfo.push({ orgId: org.orgId, - roleId + roleIds }); } } @@ -570,32 +588,28 @@ export async function validateOidcCallback( } } - // Update roles for existing auto-provisioned orgs where the role has changed - const orgsToUpdate = autoProvisionedOrgs.filter( - (currentOrg) => { - const newOrg = userOrgInfo.find( - (newOrg) => newOrg.orgId === currentOrg.orgId - ); - return newOrg && newOrg.roleId !== currentOrg.roleId; - } - ); + // Sync roles 1:1 with IdP policy for existing auto-provisioned orgs + for (const currentOrg of autoProvisionedOrgs) { + const newRole = userOrgInfo.find( + (newOrg) => newOrg.orgId === currentOrg.orgId + ); + if (!newRole) continue; - if (orgsToUpdate.length > 0) { - for (const org of orgsToUpdate) { - const newRole = userOrgInfo.find( - (newOrg) => newOrg.orgId === org.orgId + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId!), + eq(userOrgRoles.orgId, currentOrg.orgId) + ) ); - if (newRole) { - await trx - .update(userOrgs) - .set({ roleId: newRole.roleId }) - .where( - and( - eq(userOrgs.userId, userId!), - eq(userOrgs.orgId, org.orgId) - ) - ); - } + + for (const roleId of newRole.roleIds) { + await trx.insert(userOrgRoles).values({ + userId: userId!, + orgId: currentOrg.orgId, + roleId + }); } } @@ -609,6 +623,10 @@ export async function validateOidcCallback( if (orgsToAdd.length > 0) { for (const org of orgsToAdd) { + if (org.roleIds.length === 0) { + continue; + } + const [fullOrg] = await trx .select() .from(orgs) @@ -619,9 +637,9 @@ export async function validateOidcCallback( { orgId: org.orgId, userId: userId!, - roleId: org.roleId, autoProvisioned: true, }, + org.roleIds, trx ); } @@ -748,3 +766,25 @@ function hydrateOrgMapping( } return orgMapping.split("{{orgId}}").join(orgId); } + +function normalizeRoleMappingResult( + result: unknown +): string[] { + if (typeof result === "string") { + const role = result.trim(); + return role ? [role] : []; + } + + if (Array.isArray(result)) { + return [ + ...new Set( + result + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + ) + ]; + } + + return []; +} diff --git a/server/routers/integration.ts b/server/routers/integration.ts index d2b31b524..2865b4bcb 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -16,6 +16,7 @@ import { verifyApiKey, verifyApiKeyOrgAccess, verifyApiKeyHasAction, + verifyApiKeyCanSetUserOrgRoles, verifyApiKeySiteAccess, verifyApiKeyResourceAccess, verifyApiKeyTargetAccess, @@ -595,7 +596,7 @@ authenticated.post( verifyLimits, verifyApiKeyHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole + user.addUserRoleLegacy ); authenticated.post( diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index c3a261f03..35d52816e 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -16,8 +16,8 @@ import { eq, and } from "drizzle-orm"; import config from "@server/lib/config"; import { formatEndpoint, - generateSubnetProxyTargets, - SubnetProxyTarget + generateSubnetProxyTargetV2, + SubnetProxyTargetV2 } from "@server/lib/ip"; export async function buildClientConfigurationForNewtClient( @@ -143,7 +143,7 @@ export async function buildClientConfigurationForNewtClient( .from(siteResources) .where(eq(siteResources.siteId, siteId)); - const targetsToSend: SubnetProxyTarget[] = []; + const targetsToSend: SubnetProxyTargetV2[] = []; for (const resource of allSiteResources) { // Get clients associated with this specific resource @@ -168,12 +168,14 @@ export async function buildClientConfigurationForNewtClient( ) ); - const resourceTargets = generateSubnetProxyTargets( + const resourceTarget = generateSubnetProxyTargetV2( resource, resourceClients ); - targetsToSend.push(...resourceTargets); + if (resourceTarget) { + targetsToSend.push(resourceTarget); + } } return { diff --git a/server/routers/newt/createNewt.ts b/server/routers/newt/createNewt.ts index b5da405e6..68cdcacf8 100644 --- a/server/routers/newt/createNewt.ts +++ b/server/routers/newt/createNewt.ts @@ -46,7 +46,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/newt/handleConnectionLogMessage.ts b/server/routers/newt/handleConnectionLogMessage.ts new file mode 100644 index 000000000..ca1b129d2 --- /dev/null +++ b/server/routers/newt/handleConnectionLogMessage.ts @@ -0,0 +1,13 @@ +import { MessageHandler } from "@server/routers/ws"; + +export async function flushConnectionLogToDb(): Promise { + return; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + return; +} + +export const handleConnectionLogMessage: MessageHandler = async (context) => { + return; +}; diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index 6df0a8f82..fced51818 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -6,6 +6,7 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; +import { convertTargetsIfNessicary } from "../client/targets"; import { canCompress } from "@server/lib/clientVersionChecks"; export const handleGetConfigMessage: MessageHandler = async (context) => { @@ -111,13 +112,15 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { exitNode ); + const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); + return { message: { type: "newt/wg/receive-config", data: { ipAddress: site.address, peers, - targets, + targets: targetsToSend, chainId: chainId } }, diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index da25852a0..c2c4e7762 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -1,8 +1,11 @@ import { db, newts, sites } from "@server/db"; -import { hasActiveConnections, getClientConfigVersion } from "#dynamic/routers/ws"; +import { + hasActiveConnections, + getClientConfigVersion +} from "#dynamic/routers/ws"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; -import { eq, lt, isNull, and, or } from "drizzle-orm"; +import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm"; import logger from "@server/logger"; import { sendNewtSyncMessage } from "./sync"; import { recordPing } from "./pingAccumulator"; @@ -11,6 +14,7 @@ import { recordPing } from "./pingAccumulator"; let offlineCheckerInterval: NodeJS.Timeout | null = null; const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes +const OFFLINE_THRESHOLD_BANDWIDTH_MS = 8 * 60 * 1000; // 8 minutes /** * Starts the background interval that checks for newt sites that haven't @@ -56,7 +60,9 @@ export const startNewtOfflineChecker = (): void => { // Backward-compatibility check: if the newt still has an // active WebSocket connection (older clients that don't send // pings), keep the site online. - const isConnected = await hasActiveConnections(staleSite.newtId); + const isConnected = await hasActiveConnections( + staleSite.newtId + ); if (isConnected) { logger.debug( `Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online` @@ -73,6 +79,56 @@ export const startNewtOfflineChecker = (): void => { .set({ online: false }) .where(eq(sites.siteId, staleSite.siteId)); } + + // this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites + // select all of the wireguard sites to evaluate if they need to be offline due to the last bandwidth update + const allWireguardSites = await db + .select({ + siteId: sites.siteId, + online: sites.online, + lastBandwidthUpdate: sites.lastBandwidthUpdate + }) + .from(sites) + .where( + and( + eq(sites.type, "wireguard"), + not(isNull(sites.lastBandwidthUpdate)) + ) + ); + + const wireguardOfflineThreshold = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_BANDWIDTH_MS) / 1000 + ); + + // loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline + for (const site of allWireguardSites) { + const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate!).getTime() / 1000; + if ( + lastBandwidthUpdate < wireguardOfflineThreshold && + site.online + ) { + logger.info( + `Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes` + ); + + await db + .update(sites) + .set({ online: false }) + .where(eq(sites.siteId, site.siteId)); + } else if ( + lastBandwidthUpdate >= wireguardOfflineThreshold && + !site.online + ) { + logger.info( + `Marking wireguard site ${site.siteId} online: recent bandwidth update` + ); + + await db + .update(sites) + .set({ online: true }) + .where(eq(sites.siteId, site.siteId)); + } + } } catch (error) { logger.error("Error in newt offline checker interval", { error }); } diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index f31cd753b..33b5caf7c 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -8,3 +8,5 @@ export * from "./handleNewtPingRequestMessage"; export * from "./handleApplyBlueprintMessage"; export * from "./handleNewtPingMessage"; export * from "./handleNewtDisconnectingMessage"; +export * from "./handleConnectionLogMessage"; +export * from "./registerNewt"; diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts new file mode 100644 index 000000000..954acca92 --- /dev/null +++ b/server/routers/newt/registerNewt.ts @@ -0,0 +1,269 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + siteProvisioningKeys, + siteProvisioningKeyOrg, + newts, + orgs, + roles, + roleSites, + sites +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { eq, and, sql } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import { verifyPassword, hashPassword } from "@server/auth/password"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import { getUniqueSiteName } from "@server/db/names"; +import moment from "moment"; +import { build } from "@server/build"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; +import { INSPECT_MAX_BYTES } from "buffer"; +import { v } from "@faker-js/faker/dist/airline-Dz1uGqgJ"; + +const bodySchema = z.object({ + provisioningKey: z.string().nonempty(), + name: z.string().optional() +}); + +export type RegisterNewtBody = z.infer; + +export type RegisterNewtResponse = { + newtId: string; + secret: string; +}; + +export async function registerNewt( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { provisioningKey, name } = parsedBody.data; + + // Keys are in the format "siteProvisioningKeyId.secret" + const dotIndex = provisioningKey.indexOf("."); + if (dotIndex === -1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid provisioning key format" + ) + ); + } + + const provisioningKeyId = provisioningKey.substring(0, dotIndex); + const provisioningKeySecret = provisioningKey.substring(dotIndex + 1); + + // Look up the provisioning key by ID, joining to get the orgId + const [keyRecord] = await db + .select({ + siteProvisioningKeyId: + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyHash: + siteProvisioningKeys.siteProvisioningKeyHash, + orgId: siteProvisioningKeyOrg.orgId, + maxBatchSize: siteProvisioningKeys.maxBatchSize, + numUsed: siteProvisioningKeys.numUsed, + validUntil: siteProvisioningKeys.validUntil, + approveNewSites: siteProvisioningKeys.approveNewSites, + }) + .from(siteProvisioningKeys) + .innerJoin( + siteProvisioningKeyOrg, + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ) + ) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + provisioningKeyId + ) + ) + .limit(1); + + if (!keyRecord) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Invalid provisioning key" + ) + ); + } + + // Verify the secret portion against the stored hash + const validSecret = await verifyPassword( + provisioningKeySecret, + keyRecord.siteProvisioningKeyHash + ); + if (!validSecret) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Invalid provisioning key" + ) + ); + } + + if (keyRecord.maxBatchSize && keyRecord.numUsed >= keyRecord.maxBatchSize) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Provisioning key has reached its maximum usage" + ) + ); + } + + if (keyRecord.validUntil && new Date(keyRecord.validUntil) < new Date()) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Provisioning key has expired" + ) + ); + } + + const { orgId } = keyRecord; + + // Verify the org exists + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + if (!org) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + // SaaS billing check + if (build == "saas") { + const usage = await usageService.getUsage(orgId, FeatureId.SITES); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectSites = await usageService.checkLimitSet( + orgId, + FeatureId.SITES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } + ); + if (rejectSites) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Site limit exceeded. Please upgrade your plan." + ) + ); + } + } + + const niceId = await getUniqueSiteName(orgId); + const newtId = generateId(15); + const newtSecret = generateIdFromEntropySize(25); + const secretHash = await hashPassword(newtSecret); + + let newSiteId: number | undefined; + + await db.transaction(async (trx) => { + // Create the site (type "newt", name = niceId) + const [newSite] = await trx + .insert(sites) + .values({ + orgId, + name: name || niceId, + niceId, + type: "newt", + dockerSocketEnabled: true, + status: keyRecord.approveNewSites ? "approved" : "pending", + }) + .returning(); + + newSiteId = newSite.siteId; + + // Grant admin role access to the new site + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (!adminRole) { + throw new Error(`Admin role not found for org ${orgId}`); + } + + await trx.insert(roleSites).values({ + roleId: adminRole.roleId, + siteId: newSite.siteId + }); + + // Create the newt for this site + await trx.insert(newts).values({ + newtId, + secretHash, + siteId: newSite.siteId, + dateCreated: moment().toISOString() + }); + + // Consume the provisioning key — cascade removes siteProvisioningKeyOrg + await trx + .update(siteProvisioningKeys) + .set({ + lastUsed: moment().toISOString(), + numUsed: sql`${siteProvisioningKeys.numUsed} + 1` + }) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + provisioningKeyId + ) + ); + + await usageService.add(orgId, FeatureId.SITES, 1, trx); + }); + + logger.info( + `Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}` + ); + + return response(res, { + data: { + newtId, + secret: newtSecret + }, + success: true, + error: false, + message: "Newt registered successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/olm/createOlm.ts b/server/routers/olm/createOlm.ts index b5da405e6..68cdcacf8 100644 --- a/server/routers/olm/createOlm.ts +++ b/server/routers/olm/createOlm.ts @@ -46,7 +46,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/org/checkOrgUserAccess.ts b/server/routers/org/checkOrgUserAccess.ts index d9f0364e3..19e39c4fe 100644 --- a/server/routers/org/checkOrgUserAccess.ts +++ b/server/routers/org/checkOrgUserAccess.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idp, idpOidcConfig } from "@server/db"; -import { roles, userOrgs, users } from "@server/db"; +import { roles, userOrgRoles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -14,7 +14,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; async function queryUser(orgId: string, userId: string) { - const [user] = await db + const [userRow] = await db .select({ orgId: userOrgs.orgId, userId: users.userId, @@ -22,10 +22,7 @@ async function queryUser(orgId: string, userId: string) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, - isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, autoProvisioned: userOrgs.autoProvisioned, idpId: users.idpId, @@ -35,13 +32,40 @@ async function queryUser(orgId: string, userId: string) { idpAutoProvision: idp.autoProvision }) .from(userOrgs) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - return user; + + if (!userRow) return undefined; + + const roleRows = await db + .select({ + roleId: userOrgRoles.roleId, + roleName: roles.name, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const isAdmin = roleRows.some((r) => r.isAdmin); + + return { + ...userRow, + isAdmin, + roleIds: roleRows.map((r) => r.roleId), + roles: roleRows.map((r) => ({ + roleId: r.roleId, + name: r.roleName ?? "" + })) + }; } export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult; diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index ec9581e75..88f76c29c 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -9,6 +9,7 @@ import { orgs, roleActions, roles, + userOrgRoles, userOrgs, users, actions @@ -312,9 +313,13 @@ export async function createOrg( await trx.insert(userOrgs).values({ userId: req.user!.userId, orgId: newOrg[0].orgId, - roleId: roleId, isOwner: true }); + await trx.insert(userOrgRoles).values({ + userId: req.user!.userId, + orgId: newOrg[0].orgId, + roleId + }); ownerUserId = req.user!.userId; } else { // if org created by root api key, set the server admin as the owner @@ -332,9 +337,13 @@ export async function createOrg( await trx.insert(userOrgs).values({ userId: serverAdmin.userId, orgId: newOrg[0].orgId, - roleId: roleId, isOwner: true }); + await trx.insert(userOrgRoles).values({ + userId: serverAdmin.userId, + orgId: newOrg[0].orgId, + roleId + }); ownerUserId = serverAdmin.userId; } diff --git a/server/routers/org/getOrgOverview.ts b/server/routers/org/getOrgOverview.ts index d368d1b3c..fcdd7c0ed 100644 --- a/server/routers/org/getOrgOverview.ts +++ b/server/routers/org/getOrgOverview.ts @@ -117,20 +117,26 @@ export async function getOrgOverview( .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); - const [role] = await db - .select() - .from(roles) - .where(eq(roles.roleId, req.userOrg.roleId)); + const roleIds = req.userOrgRoleIds ?? []; + const roleRows = + roleIds.length > 0 + ? await db + .select({ name: roles.name, isAdmin: roles.isAdmin }) + .from(roles) + .where(inArray(roles.roleId, roleIds)) + : []; + const userRoleName = roleRows.map((r) => r.name ?? "").join(", ") ?? ""; + const isAdmin = roleRows.some((r) => r.isAdmin === true); return response(res, { data: { orgName: org[0].name, orgId: org[0].orgId, - userRoleName: role.name, + userRoleName, numSites, numUsers, numResources, - isAdmin: role.isAdmin || false, + isAdmin, isOwner: req.userOrg?.isOwner || false }, success: true, diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index 301d0203e..8e6ce649d 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, roles } from "@server/db"; -import { Org, orgs, userOrgs } from "@server/db"; +import { Org, orgs, userOrgRoles, userOrgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -82,10 +82,7 @@ export async function listUserOrgs( const { userId } = parsedParams.data; const userOrganizations = await db - .select({ - orgId: userOrgs.orgId, - roleId: userOrgs.roleId - }) + .select({ orgId: userOrgs.orgId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); @@ -116,10 +113,27 @@ export async function listUserOrgs( userOrgs, and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId)) ) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .limit(limit) .offset(offset); + const roleRows = await db + .select({ + orgId: userOrgRoles.orgId, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + inArray(userOrgRoles.orgId, userOrgIds) + ) + ); + + const orgHasAdmin = new Set( + roleRows.filter((r) => r.isAdmin).map((r) => r.orgId) + ); + const totalCountResult = await db .select({ count: sql`cast(count(*) as integer)` }) .from(orgs) @@ -133,8 +147,8 @@ export async function listUserOrgs( if (val.userOrgs && val.userOrgs.isOwner) { res.isOwner = val.userOrgs.isOwner; } - if (val.roles && val.roles.isAdmin) { - res.isAdmin = val.roles.isAdmin; + if (val.orgs && orgHasAdmin.has(val.orgs.orgId)) { + res.isAdmin = true; } if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) { res.isPrimaryOrg = val.orgs.isBillingOrg; diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 5049ac1fa..4eca9a9a6 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -34,6 +34,10 @@ const updateOrgBodySchema = z .min(build === "saas" ? 0 : -1) .optional(), settingsLogRetentionDaysAction: z + .number() + .min(build === "saas" ? 0 : -1) + .optional(), + settingsLogRetentionDaysConnection: z .number() .min(build === "saas" ? 0 : -1) .optional() @@ -164,6 +168,17 @@ export async function updateOrg( ) ); } + if ( + parsedBody.data.settingsLogRetentionDaysConnection !== undefined && + parsedBody.data.settingsLogRetentionDaysConnection > maxRetentionDays + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` + ) + ); + } } } @@ -179,7 +194,9 @@ export async function updateOrg( settingsLogRetentionDaysAccess: parsedBody.data.settingsLogRetentionDaysAccess, settingsLogRetentionDaysAction: - parsedBody.data.settingsLogRetentionDaysAction + parsedBody.data.settingsLogRetentionDaysAction, + settingsLogRetentionDaysConnection: + parsedBody.data.settingsLogRetentionDaysConnection }) .where(eq(orgs.orgId, orgId)) .returning(); @@ -197,6 +214,7 @@ export async function updateOrg( await cache.del(`org_${orgId}_retentionDays`); await cache.del(`org_${orgId}_actionDays`); await cache.del(`org_${orgId}_accessDays`); + await cache.del(`org_${orgId}_connectionDays`); return response(res, { data: updatedOrg[0], diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index e07880ac2..6cff4d23a 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -112,7 +112,7 @@ export async function createResource( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -292,7 +292,7 @@ async function createHttpResource( resourceId: newResource[0].resourceId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, @@ -385,7 +385,7 @@ async function createRawResource( resourceId: newResource[0].resourceId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index b2ce2ee7c..200ee07d4 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -14,10 +14,11 @@ import { isValidUrlGlobPattern } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidRegionId } from "@server/db/regions"; const createResourceRuleSchema = z.strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]), value: z.string().min(1), priority: z.int(), enabled: z.boolean().optional() @@ -126,6 +127,15 @@ export async function createResourceRule( ) ); } + } else if (match === "REGION") { + if (!isValidRegionId(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid region ID provided" + ) + ); + } } // Create the new resource rule diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index cd870dcbf..7a52c0a85 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -1,15 +1,14 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { Resource, resources, sites } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { db, resources } from "@server/db"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { fromError } from "zod-validation-error"; -import logger from "@server/logger"; import stoi from "@server/lib/stoi"; +import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const getResourceSchema = z.strictObject({ resourceId: z diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index eb5f8a8d9..9afd6b4f3 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -5,6 +5,7 @@ import { resources, userResources, roleResources, + userOrgRoles, userOrgs, resourcePassword, resourcePincode, @@ -32,22 +33,29 @@ export async function getUserResources( ); } - // First get the user's role in the organization - const userOrgResult = await db - .select({ - roleId: userOrgs.roleId - }) + // Check user is in organization and get their role IDs + const [userOrg] = await db + .select() .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - if (userOrgResult.length === 0) { + if (!userOrg) { return next( createHttpError(HttpCode.FORBIDDEN, "User not in organization") ); } - const userRoleId = userOrgResult[0].roleId; + const userRoleIds = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ) + .then((rows) => rows.map((r) => r.roleId)); // Get resources accessible through direct assignment or role assignment const directResourcesQuery = db @@ -55,20 +63,28 @@ export async function getUserResources( .from(userResources) .where(eq(userResources.userId, userId)); - const roleResourcesQuery = db - .select({ resourceId: roleResources.resourceId }) - .from(roleResources) - .where(eq(roleResources.roleId, userRoleId)); + const roleResourcesQuery = + userRoleIds.length > 0 + ? db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .where(inArray(roleResources.roleId, userRoleIds)) + : Promise.resolve([]); const directSiteResourcesQuery = db .select({ siteResourceId: userSiteResources.siteResourceId }) .from(userSiteResources) .where(eq(userSiteResources.userId, userId)); - const roleSiteResourcesQuery = db - .select({ siteResourceId: roleSiteResources.siteResourceId }) - .from(roleSiteResources) - .where(eq(roleSiteResources.roleId, userRoleId)); + const roleSiteResourcesQuery = + userRoleIds.length > 0 + ? db + .select({ + siteResourceId: roleSiteResources.siteResourceId + }) + .from(roleSiteResources) + .where(inArray(roleSiteResources.roleId, userRoleIds)) + : Promise.resolve([]); const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ directResourcesQuery, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index f9dd14e98..fa7ec8a48 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -305,7 +305,7 @@ export async function listResources( .where( or( eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + inArray(roleResources.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 3e8f395dd..4074fd93a 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -14,6 +14,7 @@ import { isValidUrlGlobPattern } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidRegionId } from "@server/db/regions"; // Define Zod schema for request parameters validation const updateResourceRuleParamsSchema = z.strictObject({ @@ -25,7 +26,7 @@ const updateResourceRuleParamsSchema = z.strictObject({ const updateResourceRuleSchema = z .strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), - match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]).optional(), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]).optional(), value: z.string().min(1).optional(), priority: z.int(), enabled: z.boolean().optional() @@ -166,6 +167,15 @@ export async function updateResourceRule( ) ); } + } else if (match === "REGION") { + if (!isValidRegionId(value)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid region ID provided" + ) + ); + } } } diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts index 490fe91cc..4d2797250 100644 --- a/server/routers/role/deleteRole.ts +++ b/server/routers/role/deleteRole.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roles, userOrgs } from "@server/db"; -import { eq } from "drizzle-orm"; +import { roles, userOrgRoles } from "@server/db"; +import { and, eq, exists, aliasedTable } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -114,13 +114,32 @@ export async function deleteRole( } await db.transaction(async (trx) => { - // move all users from the userOrgs table with roleId to newRoleId - await trx - .update(userOrgs) - .set({ roleId: newRoleId }) - .where(eq(userOrgs.roleId, roleId)); + const uorNewRole = aliasedTable(userOrgRoles, "user_org_roles_new"); + + // Users who already have newRoleId: drop the old assignment only (unique on userId+orgId+roleId). + await trx.delete(userOrgRoles).where( + and( + eq(userOrgRoles.roleId, roleId), + exists( + trx + .select() + .from(uorNewRole) + .where( + and( + eq(uorNewRole.userId, userOrgRoles.userId), + eq(uorNewRole.orgId, userOrgRoles.orgId), + eq(uorNewRole.roleId, newRoleId) + ) + ) + ) + ) + ); + + await trx + .update(userOrgRoles) + .set({ roleId: newRoleId }) + .where(eq(userOrgRoles.roleId, roleId)); - // delete the old role await trx.delete(roles).where(eq(roles.roleId, roleId)); }); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index cc63a2e9b..d397b2784 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -111,7 +111,7 @@ export async function createSite( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -298,7 +298,8 @@ export async function createSite( niceId, address: updatedAddress || null, type, - dockerSocketEnabled: true + dockerSocketEnabled: true, + status: "approved" }) .returning(); } else if (type == "wireguard") { @@ -355,7 +356,8 @@ export async function createSite( niceId, subnet, type, - pubKey: pubKey || null + pubKey: pubKey || null, + status: "approved" }) .returning(); } else if (type == "local") { @@ -370,7 +372,8 @@ export async function createSite( type, dockerSocketEnabled: false, online: true, - subnet: "0.0.0.0/32" + subnet: "0.0.0.0/32", + status: "approved" }) .returning(); } else { @@ -399,7 +402,7 @@ export async function createSite( siteId: newSite.siteId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the site trx.insert(userSites).values({ userId: req.user?.userId!, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index a87ad3daf..6f085d74d 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -135,6 +135,15 @@ const listSitesSchema = z.object({ .openapi({ type: "boolean", description: "Filter by online status" + }), + status: z + .enum(["pending", "approved"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["pending", "approved"], + description: "Filter by site status" }) }); @@ -156,7 +165,8 @@ function querySitesBase() { exitNodeId: sites.exitNodeId, exitNodeName: exitNodes.name, exitNodeEndpoint: exitNodes.endpoint, - remoteExitNodeId: remoteExitNodes.remoteExitNodeId + remoteExitNodeId: remoteExitNodes.remoteExitNodeId, + status: sites.status }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) @@ -235,7 +245,7 @@ export async function listSites( .where( or( eq(userSites.userId, req.user!.userId), - eq(roleSites.roleId, req.userOrgRoleId!) + inArray(roleSites.roleId, req.userOrgRoleIds!) ) ); } else { @@ -245,7 +255,7 @@ export async function listSites( .where(eq(sites.orgId, orgId)); } - const { pageSize, page, query, sort_by, order, online } = + const { pageSize, page, query, sort_by, order, online, status } = parsedQuery.data; const accessibleSiteIds = accessibleSites.map((site) => site.siteId); @@ -273,6 +283,9 @@ export async function listSites( if (typeof online !== "undefined") { conditions.push(eq(sites.online, online)); } + if (typeof status !== "undefined") { + conditions.push(eq(sites.status, status)); + } const baseQuery = querySitesBase().where(and(...conditions)); diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index ca0f76783..34d1341d7 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -19,7 +19,8 @@ const updateSiteBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), - dockerSocketEnabled: z.boolean().optional() + dockerSocketEnabled: z.boolean().optional(), + status: z.enum(["pending", "approved"]).optional(), // remoteSubnets: z.string().optional() // subdomain: z // .string() diff --git a/server/routers/siteProvisioning/types.ts b/server/routers/siteProvisioning/types.ts new file mode 100644 index 000000000..785d9dfff --- /dev/null +++ b/server/routers/siteProvisioning/types.ts @@ -0,0 +1,44 @@ +export type SiteProvisioningKeyListItem = { + siteProvisioningKeyId: string; + orgId: string; + lastChars: string; + createdAt: string; + name: string; + lastUsed: string | null; + maxBatchSize: number | null; + numUsed: number; + validUntil: string | null; + approveNewSites: boolean; +}; + +export type ListSiteProvisioningKeysResponse = { + siteProvisioningKeys: SiteProvisioningKeyListItem[]; + pagination: { total: number; limit: number; offset: number }; +}; + +export type CreateSiteProvisioningKeyResponse = { + siteProvisioningKeyId: string; + orgId: string; + name: string; + siteProvisioningKey: string; + lastChars: string; + createdAt: string; + lastUsed: string | null; + maxBatchSize: number | null; + numUsed: number; + validUntil: string | null; + approveNewSites: boolean; +}; + +export type UpdateSiteProvisioningKeyResponse = { + siteProvisioningKeyId: string; + orgId: string; + name: string; + lastChars: string; + createdAt: string; + lastUsed: string | null; + maxBatchSize: number | null; + numUsed: number; + validUntil: string | null; + approveNewSites: boolean; +}; diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index b9494776e..1485a4192 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -88,7 +88,7 @@ const createSiteResourceSchema = z }, { 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( diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 596ed9a3f..8f56ece0f 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -1,5 +1,4 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { clientSiteResources, clientSiteResourcesAssociationsCache, @@ -8,32 +7,31 @@ import { orgs, roles, roleSiteResources, + SiteResource, + siteResources, sites, Transaction, userSiteResources } from "@server/db"; -import { siteResources, SiteResource } from "@server/db"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { eq, and, ne } from "drizzle-orm"; -import { fromError } from "zod-validation-error"; -import logger from "@server/logger"; -import { OpenAPITags, registry } from "@server/openApi"; -import { updatePeerData, updateTargets } from "@server/routers/client/targets"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { generateAliasConfig, generateRemoteSubnets, - generateSubnetProxyTargets, + generateSubnetProxyTargetV2, isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; -import { - getClientSiteResourceAccess, - rebuildClientAssociationsFromSiteResource -} from "@server/lib/rebuildClientAssociations"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import { updatePeerData, updateTargets } from "@server/routers/client/targets"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, ne } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const updateSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -43,7 +41,15 @@ const updateSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255).optional(), siteId: z.int(), - // niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(), + niceId: z + .string() + .min(1) + .max(255) + .regex( + /^[a-zA-Z0-9-]+$/, + "niceId can only contain letters, numbers, and dashes" + ) + .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), mode: z.enum(["host", "cidr"]).optional(), // protocol: z.enum(["tcp", "udp"]).nullish(), @@ -167,6 +173,7 @@ export async function updateSiteResource( const { name, siteId, // because it can change + niceId, mode, destination, alias, @@ -321,7 +328,8 @@ export async function updateSiteResource( const sshPamSet = isLicensedSshPam && - (authDaemonPort !== undefined || authDaemonMode !== undefined) + (authDaemonPort !== undefined || + authDaemonMode !== undefined) ? { ...(authDaemonPort !== undefined && { authDaemonPort @@ -334,15 +342,16 @@ export async function updateSiteResource( [updatedSiteResource] = await trx .update(siteResources) .set({ - name: name, - siteId: siteId, - mode: mode, - destination: destination, - enabled: enabled, + name, + siteId, + niceId, + mode, + destination, + enabled, alias: alias && alias.trim() ? alias : null, - tcpPortRangeString: tcpPortRangeString, - udpPortRangeString: udpPortRangeString, - disableIcmp: disableIcmp, + tcpPortRangeString, + udpPortRangeString, + disableIcmp, ...sshPamSet }) .where( @@ -423,7 +432,8 @@ export async function updateSiteResource( // Update the site resource const sshPamSet = isLicensedSshPam && - (authDaemonPort !== undefined || authDaemonMode !== undefined) + (authDaemonPort !== undefined || + authDaemonMode !== undefined) ? { ...(authDaemonPort !== undefined && { authDaemonPort @@ -608,19 +618,23 @@ export async function handleMessagingForUpdatedSiteResource( // Only update targets on newt if destination changed if (destinationChanged || portRangesChanged) { - const oldTargets = generateSubnetProxyTargets( + const oldTarget = generateSubnetProxyTargetV2( existingSiteResource, mergedAllClients ); - const newTargets = generateSubnetProxyTargets( + const newTarget = generateSubnetProxyTargetV2( updatedSiteResource, mergedAllClients ); - await updateTargets(newt.newtId, { - oldTargets: oldTargets, - newTargets: newTargets - }, newt.version); + await updateTargets( + newt.newtId, + { + oldTargets: oldTarget ? [oldTarget] : [], + newTargets: newTarget ? [newTarget] : [] + }, + newt.version + ); } const olmJobs: Promise[] = []; diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index e4ef45f3b..18e932afa 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -40,6 +40,7 @@ function queryTargets(resourceId: number) { resourceId: targets.resourceId, siteId: targets.siteId, siteType: sites.type, + siteName: sites.name, hcEnabled: targetHealthCheck.hcEnabled, hcPath: targetHealthCheck.hcPath, hcScheme: targetHealthCheck.hcScheme, diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index dd31f5f1b..1f9eff716 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -188,6 +188,8 @@ export async function updateTarget( ); } + const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; + const [updatedTarget] = await db .update(targets) .set({ @@ -200,8 +202,8 @@ export async function updateTarget( path: parsedBody.data.path, pathMatchType: parsedBody.data.pathMatchType, priority: parsedBody.data.priority, - rewritePath: parsedBody.data.rewritePath, - rewritePathType: parsedBody.data.rewritePathType + rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, + rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType }) .where(eq(targets.targetId, targetId)) .returning(); diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index fa76190ff..02f890604 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -30,12 +30,15 @@ export async function traefikConfigProvider( traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { [badgerMiddlewareName]: { - apiBaseUrl: new URL( - "/api/v1", - `http://${ - config.getRawConfig().server.internal_hostname - }:${config.getRawConfig().server.internal_port}` - ).href, + apiBaseUrl: + config.getRawConfig().server.badger_override || + new URL( + "/api/v1", + `http://${ + config.getRawConfig().server + .internal_hostname + }:${config.getRawConfig().server.internal_port}` + ).href, userSessionCookieName: config.getRawConfig().server.session_cookie_name, @@ -61,7 +64,7 @@ export async function traefikConfigProvider( return res.status(HttpCode.OK).json(traefikConfig); } catch (e) { - logger.error(`Failed to build Traefik config: ${e}`); + logger.error(e); return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ error: "Failed to build Traefik config" }); diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 388db4a31..88010e580 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, orgs, UserOrg } from "@server/db"; -import { roles, userInvites, userOrgs, users } from "@server/db"; -import { eq, and, inArray, ne } from "drizzle-orm"; +import { db, orgs } from "@server/db"; +import { roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -141,17 +141,34 @@ export async function acceptInvite( ); } - let roleId: number; - // get the role to make sure it exists - const existingRole = await db + const inviteRoleRows = await db + .select({ roleId: userInviteRoles.roleId }) + .from(userInviteRoles) + .where(eq(userInviteRoles.inviteId, inviteId)); + + const inviteRoleIds = [ + ...new Set(inviteRoleRows.map((r) => r.roleId)) + ]; + if (inviteRoleIds.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This invitation has no roles. Please contact an admin." + ) + ); + } + + const existingRoles = await db .select() .from(roles) - .where(eq(roles.roleId, existingInvite.roleId)) - .limit(1); - if (existingRole.length) { - roleId = existingRole[0].roleId; - } else { - // TODO: use the default role on the org instead of failing + .where( + and( + eq(roles.orgId, existingInvite.orgId), + inArray(roles.roleId, inviteRoleIds) + ) + ); + + if (existingRoles.length !== inviteRoleIds.length) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -165,9 +182,9 @@ export async function acceptInvite( org, { userId: existingUser[0].userId, - orgId: existingInvite.orgId, - roleId: existingInvite.roleId + orgId: existingInvite.orgId }, + inviteRoleIds, trx ); diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts new file mode 100644 index 000000000..db0c6182f --- /dev/null +++ b/server/routers/user/addUserRoleLegacy.ts @@ -0,0 +1,159 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import stoi from "@server/lib/stoi"; +import { clients, db } from "@server/db"; +import { userOrgRoles, userOrgs, roles } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; + +/** Legacy path param order: /role/:roleId/add/:userId */ +const addUserRoleLegacyParamsSchema = z.strictObject({ + roleId: z.string().transform(stoi).pipe(z.number()), + userId: z.string() +}); + +registry.registerPath({ + method: "post", + path: "/role/{roleId}/add/{userId}", + description: + "Legacy: set exactly one role for the user (replaces any other roles the user has in the org).", + tags: [OpenAPITags.Role, OpenAPITags.User], + request: { + params: addUserRoleLegacyParamsSchema + }, + responses: {} +}); + +export async function addUserRoleLegacy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = addUserRoleLegacyParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, roleId } = parsedParams.data; + + if (req.user && !req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + if (!role) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") + ); + } + + const [existingUser] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)) + ) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found or does not belong to the specified organization" + ) + ); + } + + if (existingUser.isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot change the role of the owner of the organization" + ) + ); + } + + const [roleInOrg] = await db + .select() + .from(roles) + .where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId))) + .limit(1); + + if (!roleInOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the specified organization" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId) + ) + ); + + await trx.insert(userOrgRoles).values({ + userId, + orgId: role.orgId, + roleId + }); + + const orgClients = await trx + .select() + .from(clients) + .where( + and( + eq(clients.userId, userId), + eq(clients.orgId, role.orgId) + ) + ); + + for (const orgClient of orgClients) { + await rebuildClientAssociationsFromClient(orgClient, trx); + } + }); + + return response(res, { + data: { ...existingUser, roleId }, + success: true, + error: false, + message: "Role added to user successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 3b8e70f7a..ddc37d3a2 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -6,8 +6,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { db, orgs, UserOrg } from "@server/db"; -import { and, eq, inArray, ne } from "drizzle-orm"; +import { db, orgs } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { generateId } from "@server/auth/sessions/app"; import { usageService } from "@server/lib/billing/usageService"; @@ -15,21 +15,43 @@ import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { assignUserToOrg } from "@server/lib/userOrg"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const bodySchema = z.strictObject({ - email: z.string().email().toLowerCase().optional(), - username: z.string().nonempty().toLowerCase(), - name: z.string().optional(), - type: z.enum(["internal", "oidc"]).optional(), - idpId: z.number().optional(), - roleId: z.number() -}); +const bodySchema = z + .strictObject({ + email: z.string().email().toLowerCase().optional(), + username: z.string().nonempty().toLowerCase(), + name: z.string().optional(), + type: z.enum(["internal", "oidc"]).optional(), + idpId: z.number().optional(), + roleIds: z.array(z.number().int().positive()).min(1).optional(), + roleId: z.number().int().positive().optional() + }) + .refine( + (d) => + (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, + { message: "roleIds or roleId is required", path: ["roleIds"] } + ) + .transform((data) => ({ + email: data.email, + username: data.username, + name: data.name, + type: data.type, + idpId: data.idpId, + roleIds: [ + ...new Set( + data.roleIds && data.roleIds.length > 0 + ? data.roleIds + : [data.roleId!] + ) + ] + })); export type CreateOrgUserResponse = {}; @@ -78,7 +100,8 @@ export async function createOrgUser( } const { orgId } = parsedParams.data; - const { username, email, name, type, idpId, roleId } = parsedBody.data; + const { username, email, name, type, idpId, roleIds: uniqueRoleIds } = + parsedBody.data; if (build == "saas") { const usage = await usageService.getUsage(orgId, FeatureId.USERS); @@ -109,17 +132,6 @@ export async function createOrgUser( } } - const [role] = await db - .select() - .from(roles) - .where(eq(roles.roleId, roleId)); - - if (!role) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Role ID not found") - ); - } - if (type === "internal") { return next( createHttpError( @@ -152,6 +164,38 @@ export async function createOrgUser( ); } + const supportsMultiRole = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.FullRbac] + ); + if (!supportsMultiRole && uniqueRoleIds.length > 1) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Multiple roles per user require a subscription or license that includes full RBAC." + ) + ); + } + + const orgRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + inArray(roles.roleId, uniqueRoleIds) + ) + ); + + if (orgRoles.length !== uniqueRoleIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid role ID or role does not belong to this organization" + ) + ); + } + const [org] = await db .select() .from(orgs) @@ -221,12 +265,16 @@ export async function createOrgUser( ); } - await assignUserToOrg(org, { - orgId, - userId: existingUser.userId, - roleId: role.roleId, - autoProvisioned: false - }, trx); + await assignUserToOrg( + org, + { + orgId, + userId: existingUser.userId, + autoProvisioned: false, + }, + uniqueRoleIds, + trx + ); } else { userId = generateId(15); @@ -244,12 +292,16 @@ export async function createOrgUser( }) .returning(); - await assignUserToOrg(org, { - orgId, - userId: newUser.userId, - roleId: role.roleId, - autoProvisioned: false - }, trx); + await assignUserToOrg( + org, + { + orgId, + userId: newUser.userId, + autoProvisioned: false, + }, + uniqueRoleIds, + trx + ); } await calculateUserClientsForOrgs(userId, trx); diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index b117ca569..c415e186c 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idp, idpOidcConfig } from "@server/db"; -import { roles, userOrgs, users } from "@server/db"; +import { roles, userOrgRoles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -12,7 +12,7 @@ import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { OpenAPITags, registry } from "@server/openApi"; export async function queryUser(orgId: string, userId: string) { - const [user] = await db + const [userRow] = await db .select({ orgId: userOrgs.orgId, userId: users.userId, @@ -20,10 +20,7 @@ export async function queryUser(orgId: string, userId: string) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, - isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, autoProvisioned: userOrgs.autoProvisioned, idpId: users.idpId, @@ -33,13 +30,40 @@ export async function queryUser(orgId: string, userId: string) { idpAutoProvision: idp.autoProvision }) .from(userOrgs) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - return user; + + if (!userRow) return undefined; + + const roleRows = await db + .select({ + roleId: userOrgRoles.roleId, + roleName: roles.name, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const isAdmin = roleRows.some((r) => r.isAdmin); + + return { + ...userRow, + isAdmin, + roleIds: roleRows.map((r) => r.roleId), + roles: roleRows.map((r) => ({ + roleId: r.roleId, + name: r.roleName ?? "" + })) + }; } export type GetOrgUserResponse = NonNullable< diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 6aa2bf792..690a013f6 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -1,7 +1,8 @@ export * from "./getUser"; export * from "./removeUserOrg"; export * from "./listUsers"; -export * from "./addUserRole"; +export * from "./types"; +export * from "./addUserRoleLegacy"; export * from "./inviteUser"; export * from "./acceptInvite"; export * from "./getOrgUser"; diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index b0632da9e..7ac1849b9 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs, roles, userInvites, userOrgs, users } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -18,22 +18,44 @@ import { OpenAPITags, registry } from "@server/openApi"; import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { build } from "@server/build"; import cache from "#dynamic/lib/cache"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; const inviteUserParamsSchema = z.strictObject({ orgId: z.string() }); -const inviteUserBodySchema = z.strictObject({ - email: z.email().toLowerCase(), - roleId: z.number(), - validHours: z.number().gt(0).lte(168), - sendEmail: z.boolean().optional(), - regenerate: z.boolean().optional() -}); +const inviteUserBodySchema = z + .strictObject({ + email: z.email().toLowerCase(), + roleIds: z.array(z.number().int().positive()).min(1).optional(), + roleId: z.number().int().positive().optional(), + validHours: z.number().gt(0).lte(168), + sendEmail: z.boolean().optional(), + regenerate: z.boolean().optional() + }) + .refine( + (d) => + (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, + { message: "roleIds or roleId is required", path: ["roleIds"] } + ) + .transform((data) => ({ + email: data.email, + validHours: data.validHours, + sendEmail: data.sendEmail, + regenerate: data.regenerate, + roleIds: [ + ...new Set( + data.roleIds && data.roleIds.length > 0 + ? data.roleIds + : [data.roleId!] + ) + ] + })); -export type InviteUserBody = z.infer; +export type InviteUserBody = z.input; export type InviteUserResponse = { inviteLink: string; @@ -88,7 +110,7 @@ export async function inviteUser( const { email, validHours, - roleId, + roleIds: uniqueRoleIds, sendEmail: doEmail, regenerate } = parsedBody.data; @@ -105,14 +127,30 @@ export async function inviteUser( ); } - // Validate that the roleId belongs to the target organization - const [role] = await db - .select() - .from(roles) - .where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId))) - .limit(1); + const supportsMultiRole = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.FullRbac] + ); + if (!supportsMultiRole && uniqueRoleIds.length > 1) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Multiple roles per user require a subscription or license that includes full RBAC." + ) + ); + } - if (!role) { + const orgRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + inArray(roles.roleId, uniqueRoleIds) + ) + ); + + if (orgRoles.length !== uniqueRoleIds.length) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -191,7 +229,8 @@ export async function inviteUser( } if (existingInvite.length) { - const attempts = (await cache.get(email)) || 0; + const attempts = + (await cache.get("regenerateInvite:" + email)) || 0; if (attempts >= 3) { return next( createHttpError( @@ -273,9 +312,11 @@ export async function inviteUser( orgId, email, expiresAt, - tokenHash, - roleId + tokenHash }); + await trx.insert(userInviteRoles).values( + uniqueRoleIds.map((roleId) => ({ inviteId, roleId })) + ); }); const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; diff --git a/server/routers/user/listInvitations.ts b/server/routers/user/listInvitations.ts index 2733c8395..1f4bcc02c 100644 --- a/server/routers/user/listInvitations.ts +++ b/server/routers/user/listInvitations.ts @@ -1,11 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userInvites, roles } from "@server/db"; +import { userInvites, userInviteRoles, roles } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql } from "drizzle-orm"; +import { sql, eq, and, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -29,24 +29,66 @@ const listInvitationsQuerySchema = z.strictObject({ .pipe(z.int().nonnegative()) }); -async function queryInvitations(orgId: string, limit: number, offset: number) { - return await db +export type InvitationListRow = { + inviteId: string; + email: string; + expiresAt: number; + roles: { roleId: number; roleName: string | null }[]; +}; + +async function queryInvitations( + orgId: string, + limit: number, + offset: number +): Promise { + const inviteRows = await db .select({ inviteId: userInvites.inviteId, email: userInvites.email, - expiresAt: userInvites.expiresAt, - roleId: userInvites.roleId, - roleName: roles.name + expiresAt: userInvites.expiresAt }) .from(userInvites) - .leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`) - .where(sql`${userInvites.orgId} = ${orgId}`) + .where(eq(userInvites.orgId, orgId)) .limit(limit) .offset(offset); + + if (inviteRows.length === 0) { + return []; + } + + const inviteIds = inviteRows.map((r) => r.inviteId); + const roleRows = await db + .select({ + inviteId: userInviteRoles.inviteId, + roleId: userInviteRoles.roleId, + roleName: roles.name + }) + .from(userInviteRoles) + .innerJoin(roles, eq(userInviteRoles.roleId, roles.roleId)) + .where( + and(eq(roles.orgId, orgId), inArray(userInviteRoles.inviteId, inviteIds)) + ); + + const rolesByInvite = new Map< + string, + { roleId: number; roleName: string | null }[] + >(); + for (const row of roleRows) { + const list = rolesByInvite.get(row.inviteId) ?? []; + list.push({ roleId: row.roleId, roleName: row.roleName }); + rolesByInvite.set(row.inviteId, list); + } + + return inviteRows.map((inv) => ({ + inviteId: inv.inviteId, + email: inv.email, + expiresAt: inv.expiresAt, + roles: rolesByInvite.get(inv.inviteId) ?? [] + })); } export type ListInvitationsResponse = { - invitations: NonNullable>>; + invitations: InvitationListRow[]; pagination: { total: number; limit: number; offset: number }; }; @@ -95,7 +137,7 @@ export async function listInvitations( const [{ count }] = await db .select({ count: sql`count(*)` }) .from(userInvites) - .where(sql`${userInvites.orgId} = ${orgId}`); + .where(eq(userInvites.orgId, orgId)); return response(res, { data: { diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 40ca7ef2f..fe7f6b250 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,15 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idpOidcConfig } from "@server/db"; -import { idp, roles, userOrgs, users } from "@server/db"; +import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq } from "drizzle-orm"; const listUsersParamsSchema = z.strictObject({ orgId: z.string() @@ -31,7 +30,7 @@ const listUsersSchema = z.strictObject({ }); async function queryUsers(orgId: string, limit: number, offset: number) { - return await db + const rows = await db .select({ id: users.userId, email: users.email, @@ -41,8 +40,6 @@ async function queryUsers(orgId: string, limit: number, offset: number) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, idpName: idp.name, idpId: users.idpId, @@ -52,12 +49,48 @@ async function queryUsers(orgId: string, limit: number, offset: number) { }) .from(users) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .where(eq(userOrgs.orgId, orgId)) .limit(limit) .offset(offset); + + const userIds = rows.map((r) => r.id); + const roleRows = + userIds.length === 0 + ? [] + : await db + .select({ + userId: userOrgRoles.userId, + roleId: userOrgRoles.roleId, + roleName: roles.name + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.orgId, orgId), + inArray(userOrgRoles.userId, userIds) + ) + ); + + const rolesByUser = new Map< + string, + { roleId: number; roleName: string }[] + >(); + for (const r of roleRows) { + const list = rolesByUser.get(r.userId) ?? []; + list.push({ roleId: r.roleId, roleName: r.roleName ?? "" }); + rolesByUser.set(r.userId, list); + } + + return rows.map((row) => { + const userRoles = rolesByUser.get(row.id) ?? []; + return { + ...row, + roles: userRoles + }; + }); } export type ListUsersResponse = { diff --git a/server/routers/user/myDevice.ts b/server/routers/user/myDevice.ts index 3d656fc7a..591d6178e 100644 --- a/server/routers/user/myDevice.ts +++ b/server/routers/user/myDevice.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { db, Olm, olms, orgs, userOrgs } from "@server/db"; +import { db, Olm, olms, orgs, userOrgRoles, userOrgs } from "@server/db"; import { idp, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -85,16 +85,31 @@ export async function myDevice( .from(olms) .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); - const userOrganizations = await db + const userOrgRows = await db .select({ orgId: userOrgs.orgId, - orgName: orgs.name, - roleId: userOrgs.roleId + orgName: orgs.name }) .from(userOrgs) .where(eq(userOrgs.userId, userId)) .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)); + const roleRows = await db + .select({ + orgId: userOrgRoles.orgId, + roleId: userOrgRoles.roleId + }) + .from(userOrgRoles) + .where(eq(userOrgRoles.userId, userId)); + + const roleByOrg = new Map( + roleRows.map((r) => [r.orgId, r.roleId]) + ); + const userOrganizations = userOrgRows.map((row) => ({ + ...row, + roleId: roleByOrg.get(row.orgId) ?? 0 + })); + return response(res, { data: { user, diff --git a/server/routers/user/types.ts b/server/routers/user/types.ts new file mode 100644 index 000000000..bd5b54efa --- /dev/null +++ b/server/routers/user/types.ts @@ -0,0 +1,18 @@ +import type { UserOrg } from "@server/db"; + +export type AddUserRoleResponse = { + userId: string; + roleId: number; +}; + +/** Legacy POST /role/:roleId/add/:userId response shape (membership + effective role). */ +export type AddUserRoleLegacyResponse = UserOrg & { roleId: number }; + +export type SetUserOrgRolesParams = { + orgId: string; + userId: string; +}; + +export type SetUserOrgRolesBody = { + roleIds: number[]; +}; diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 1ace73474..9ba0b9767 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -21,6 +21,7 @@ import m12 from "./scriptsPg/1.15.0"; import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; +import m16 from "./scriptsPg/1.17.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -41,7 +42,8 @@ const migrations = [ { version: "1.15.0", run: m12 }, { version: "1.15.3", run: m13 }, { version: "1.15.4", run: m14 }, - { version: "1.16.0", run: m15 } + { version: "1.16.0", run: m15 }, + { version: "1.17.0", run: m16 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index da7e6b6d1..45a29ec29 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -39,6 +39,7 @@ import m33 from "./scriptsSqlite/1.15.0"; import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; +import m37 from "./scriptsSqlite/1.17.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -75,7 +76,8 @@ const migrations = [ { version: "1.15.0", run: m33 }, { version: "1.15.3", run: m34 }, { version: "1.15.4", run: m35 }, - { version: "1.16.0", run: m36 } + { version: "1.16.0", run: m36 }, + { version: "1.17.0", run: m37 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.17.0.ts b/server/setup/scriptsPg/1.17.0.ts new file mode 100644 index 000000000..ea66948ab --- /dev/null +++ b/server/setup/scriptsPg/1.17.0.ts @@ -0,0 +1,239 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.17.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Query existing roleId data from userOrgs before the transaction destroys it + const existingRolesQuery = await db.execute( + sql`SELECT "userId", "orgId", "roleId" FROM "userOrgs" WHERE "roleId" IS NOT NULL` + ); + const existingUserOrgRoles = existingRolesQuery.rows as { + userId: string; + orgId: string; + roleId: number; + }[]; + + console.log( + `Found ${existingUserOrgRoles.length} existing userOrgs role assignment(s) to migrate` + ); + + // Query existing roleId data from userInvites before the transaction destroys it + const existingInviteRolesQuery = await db.execute( + sql`SELECT "inviteId", "roleId" FROM "userInvites" WHERE "roleId" IS NOT NULL` + ); + const existingUserInviteRoles = existingInviteRolesQuery.rows as { + inviteId: string; + roleId: number; + }[]; + + console.log( + `Found ${existingUserInviteRoles.length} existing userInvites role assignment(s) to migrate` + ); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "bannedEmails" ( + "email" varchar(255) PRIMARY KEY NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "bannedIps" ( + "ip" varchar(255) PRIMARY KEY NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "connectionAuditLog" ( + "id" serial PRIMARY KEY NOT NULL, + "sessionId" text NOT NULL, + "siteResourceId" integer, + "orgId" text, + "siteId" integer, + "clientId" integer, + "userId" text, + "sourceAddr" text NOT NULL, + "destAddr" text NOT NULL, + "protocol" text NOT NULL, + "startedAt" integer NOT NULL, + "endedAt" integer, + "bytesTx" integer, + "bytesRx" integer + ); + `); + + await db.execute(sql` + CREATE TABLE "siteProvisioningKeyOrg" ( + "siteProvisioningKeyId" varchar(255) NOT NULL, + "orgId" varchar(255) NOT NULL, + CONSTRAINT "siteProvisioningKeyOrg_siteProvisioningKeyId_orgId_pk" PRIMARY KEY("siteProvisioningKeyId","orgId") + ); + `); + await db.execute(sql` + CREATE TABLE "siteProvisioningKeys" ( + "siteProvisioningKeyId" varchar(255) PRIMARY KEY NOT NULL, + "name" varchar(255) NOT NULL, + "siteProvisioningKeyHash" text NOT NULL, + "lastChars" varchar(4) NOT NULL, + "dateCreated" varchar(255) NOT NULL, + "lastUsed" varchar(255), + "maxBatchSize" integer, + "numUsed" integer DEFAULT 0 NOT NULL, + "validUntil" varchar(255) + ); + `); + + await db.execute(sql` + CREATE TABLE "userInviteRoles" ( + "inviteId" varchar NOT NULL, + "roleId" integer NOT NULL, + CONSTRAINT "userInviteRoles_inviteId_roleId_pk" PRIMARY KEY("inviteId","roleId") + ); + `); + + await db.execute(sql` + CREATE TABLE "userOrgRoles" ( + "userId" varchar NOT NULL, + "orgId" varchar NOT NULL, + "roleId" integer NOT NULL, + CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId") + ); + `); + await db.execute( + sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";` + ); + await db.execute( + sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute(sql`ALTER TABLE "userOrgs" DROP COLUMN "roleId";`); + + await db.execute( + sql`ALTER TABLE "userInvites" DROP CONSTRAINT "userInvites_roleId_roles_roleId_fk";` + ); + await db.execute( + sql`ALTER TABLE "accessAuditLog" ADD COLUMN "siteResourceId" integer;` + ); + await db.execute( + sql`ALTER TABLE "clientSitesAssociationsCache" ADD COLUMN "isJitMode" boolean DEFAULT false NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "domains" ADD COLUMN "errorMessage" text;` + ); + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysConnection" integer DEFAULT 0 NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "sites" ADD COLUMN "lastPing" integer;` + ); + await db.execute( + sql`ALTER TABLE "user" ADD COLUMN "marketingEmailConsent" boolean DEFAULT false;` + ); + await db.execute(sql`ALTER TABLE "user" ADD COLUMN "locale" varchar;`); + await db.execute( + sql`ALTER TABLE "connectionAuditLog" ADD CONSTRAINT "connectionAuditLog_siteResourceId_siteResources_siteResourceId_fk" FOREIGN KEY ("siteResourceId") REFERENCES "public"."siteResources"("siteResourceId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "connectionAuditLog" ADD CONSTRAINT "connectionAuditLog_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "connectionAuditLog" ADD CONSTRAINT "connectionAuditLog_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "connectionAuditLog" ADD CONSTRAINT "connectionAuditLog_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "connectionAuditLog" ADD CONSTRAINT "connectionAuditLog_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "siteProvisioningKeyOrg" ADD CONSTRAINT "siteProvisioningKeyOrg_siteProvisioningKeyId_siteProvisioningKeys_siteProvisioningKeyId_fk" FOREIGN KEY ("siteProvisioningKeyId") REFERENCES "public"."siteProvisioningKeys"("siteProvisioningKeyId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "siteProvisioningKeyOrg" ADD CONSTRAINT "siteProvisioningKeyOrg_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "userInviteRoles" ADD CONSTRAINT "userInviteRoles_inviteId_userInvites_inviteId_fk" FOREIGN KEY ("inviteId") REFERENCES "public"."userInvites"("inviteId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "userInviteRoles" ADD CONSTRAINT "userInviteRoles_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`CREATE INDEX "idx_accessAuditLog_startedAt" ON "connectionAuditLog" USING btree ("startedAt");` + ); + await db.execute( + sql`CREATE INDEX "idx_accessAuditLog_org_startedAt" ON "connectionAuditLog" USING btree ("orgId","startedAt");` + ); + await db.execute( + sql`CREATE INDEX "idx_accessAuditLog_siteResourceId" ON "connectionAuditLog" USING btree ("siteResourceId");` + ); + await db.execute(sql`ALTER TABLE "userInvites" DROP COLUMN "roleId";`); + await db.execute(sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`); + await db.execute(sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + // Re-insert the preserved invite role assignments into the new userInviteRoles table + if (existingUserInviteRoles.length > 0) { + try { + for (const row of existingUserInviteRoles) { + await db.execute(sql` + INSERT INTO "userInviteRoles" ("inviteId", "roleId") + VALUES (${row.inviteId}, ${row.roleId}) + ON CONFLICT DO NOTHING + `); + } + + console.log( + `Migrated ${existingUserInviteRoles.length} role assignment(s) into userInviteRoles` + ); + } catch (e) { + console.error( + "Error while migrating role assignments into userInviteRoles:", + e + ); + throw e; + } + } + + // Re-insert the preserved role assignments into the new userOrgRoles table + if (existingUserOrgRoles.length > 0) { + try { + for (const row of existingUserOrgRoles) { + await db.execute(sql` + INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId") + VALUES (${row.userId}, ${row.orgId}, ${row.roleId}) + ON CONFLICT DO NOTHING + `); + } + + console.log( + `Migrated ${existingUserOrgRoles.length} role assignment(s) into userOrgRoles` + ); + } catch (e) { + console.error( + "Error while migrating role assignments into userOrgRoles:", + e + ); + throw e; + } + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.17.0.ts b/server/setup/scriptsSqlite/1.17.0.ts new file mode 100644 index 000000000..28877f16e --- /dev/null +++ b/server/setup/scriptsSqlite/1.17.0.ts @@ -0,0 +1,245 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.17.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + // Query existing roleId data from userOrgs before the transaction destroys it + const existingUserOrgRoles = db + .prepare( + `SELECT "userId", "orgId", "roleId" FROM 'userOrgs' WHERE "roleId" IS NOT NULL` + ) + .all() as { userId: string; orgId: string; roleId: number }[]; + + console.log( + `Found ${existingUserOrgRoles.length} existing userOrgs role assignment(s) to migrate` + ); + + // Query existing roleId data from userInvites before the transaction destroys it + const existingUserInviteRoles = db + .prepare( + `SELECT "inviteId", "roleId" FROM 'userInvites' WHERE "roleId" IS NOT NULL` + ) + .all() as { inviteId: string; roleId: number }[]; + + console.log( + `Found ${existingUserInviteRoles.length} existing userInvites role assignment(s) to migrate` + ); + + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'bannedEmails' ( + 'email' text PRIMARY KEY NOT NULL + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'bannedIps' ( + 'ip' text PRIMARY KEY NOT NULL + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'connectionAuditLog' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'sessionId' text NOT NULL, + 'siteResourceId' integer, + 'orgId' text, + 'siteId' integer, + 'clientId' integer, + 'userId' text, + 'sourceAddr' text NOT NULL, + 'destAddr' text NOT NULL, + 'protocol' text NOT NULL, + 'startedAt' integer NOT NULL, + 'endedAt' integer, + 'bytesTx' integer, + 'bytesRx' integer, + FOREIGN KEY ('siteResourceId') REFERENCES 'siteResources'('siteResourceId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('clientId') REFERENCES 'clients'('clientId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare(`CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`).run(); + db.prepare(`CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`).run(); + db.prepare(`CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`).run(); + + db.prepare( + ` + CREATE TABLE 'siteProvisioningKeyOrg' ( + 'siteProvisioningKeyId' text NOT NULL, + 'orgId' text NOT NULL, + PRIMARY KEY('siteProvisioningKeyId', 'orgId'), + FOREIGN KEY ('siteProvisioningKeyId') REFERENCES 'siteProvisioningKeys'('siteProvisioningKeyId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE 'siteProvisioningKeys' ( + 'siteProvisioningKeyId' text PRIMARY KEY NOT NULL, + 'name' text NOT NULL, + 'siteProvisioningKeyHash' text NOT NULL, + 'lastChars' text NOT NULL, + 'dateCreated' text NOT NULL, + 'lastUsed' text, + 'maxBatchSize' integer, + 'numUsed' integer DEFAULT 0 NOT NULL, + 'validUntil' text + ); + ` + ).run(); + + db.prepare( + ` + CREATE TABLE 'userOrgRoles' ( + 'userId' text NOT NULL, + 'orgId' text NOT NULL, + 'roleId' integer NOT NULL, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `CREATE UNIQUE INDEX 'userOrgRoles_userId_orgId_roleId_unique' ON 'userOrgRoles' ('userId','orgId','roleId');` + ).run(); + + db.prepare( + ` + CREATE TABLE '__new_userOrgs' ( + 'userId' text NOT NULL, + 'orgId' text NOT NULL, + 'isOwner' integer DEFAULT false NOT NULL, + 'autoProvisioned' integer DEFAULT false, + 'pamUsername' text, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';` + ).run(); + db.prepare(`DROP TABLE 'userOrgs';`).run(); + db.prepare( + `ALTER TABLE '__new_userOrgs' RENAME TO 'userOrgs';` + ).run(); + db.prepare( + ` + CREATE TABLE 'userInviteRoles' ( + 'inviteId' text NOT NULL, + 'roleId' integer NOT NULL, + PRIMARY KEY('inviteId', 'roleId'), + FOREIGN KEY ('inviteId') REFERENCES 'userInvites'('inviteId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + ` + CREATE TABLE '__new_userInvites' ( + 'inviteId' text PRIMARY KEY NOT NULL, + 'orgId' text NOT NULL, + 'email' text NOT NULL, + 'expiresAt' integer NOT NULL, + 'token' text NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + db.prepare( + `INSERT INTO '__new_userInvites'("inviteId", "orgId", "email", "expiresAt", "token") SELECT "inviteId", "orgId", "email", "expiresAt", "token" FROM 'userInvites';` + ).run(); + db.prepare(`DROP TABLE 'userInvites';`).run(); + db.prepare( + `ALTER TABLE '__new_userInvites' RENAME TO 'userInvites';` + ).run(); + + db.prepare( + `ALTER TABLE 'accessAuditLog' ADD 'siteResourceId' integer;` + ).run(); + db.prepare( + `ALTER TABLE 'clientSitesAssociationsCache' ADD 'isJitMode' integer DEFAULT false NOT NULL;` + ).run(); + db.prepare(`ALTER TABLE 'domains' ADD 'errorMessage' text;`).run(); + db.prepare( + `ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysConnection' integer DEFAULT 0 NOT NULL;` + ).run(); + db.prepare(`ALTER TABLE 'sites' ADD 'lastPing' integer;`).run(); + db.prepare( + `ALTER TABLE 'user' ADD 'marketingEmailConsent' integer DEFAULT false;` + ).run(); + db.prepare(`ALTER TABLE 'user' ADD 'locale' text;`).run(); + db.prepare(`ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`).run(); + db.prepare(`ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`).run(); + })(); + + db.pragma("foreign_keys = ON"); + + // Re-insert the preserved invite role assignments into the new userInviteRoles table + if (existingUserInviteRoles.length > 0) { + const insertUserInviteRole = db.prepare( + `INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId") VALUES (?, ?)` + ); + + const insertAll = db.transaction(() => { + for (const row of existingUserInviteRoles) { + insertUserInviteRole.run(row.inviteId, row.roleId); + } + }); + + insertAll(); + + console.log( + `Migrated ${existingUserInviteRoles.length} role assignment(s) into userInviteRoles` + ); + } + + // Re-insert the preserved role assignments into the new userOrgRoles table + if (existingUserOrgRoles.length > 0) { + const insertUserOrgRole = db.prepare( + `INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)` + ); + + const insertAll = db.transaction(() => { + for (const row of existingUserOrgRoles) { + insertUserOrgRole.run(row.userId, row.orgId, row.roleId); + } + }); + + insertAll(); + + console.log( + `Migrated ${existingUserOrgRoles.length} role assignment(s) into userOrgRoles` + ); + } + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/types/Auth.ts b/server/types/Auth.ts index 8e222987c..398c02406 100644 --- a/server/types/Auth.ts +++ b/server/types/Auth.ts @@ -5,5 +5,5 @@ import { Session } from "@server/db"; export interface AuthenticatedRequest extends Request { user: User; session: Session; - userOrgRoleId?: number; + userOrgRoleIds?: number[]; } diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 7d4bece1e..37334e342 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -45,8 +45,17 @@ import { useTranslations } from "next-intl"; import { AxiosResponse } from "axios"; import { ListRolesResponse } from "@server/routers/role"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + compileRoleMappingExpression, + createMappingBuilderRule, + detectRoleMappingConfig, + ensureMappingBuilderRuleIds, + MappingBuilderRule, + RoleMappingMode +} from "@app/lib/idpRoleMapping"; export default function GeneralPage() { const { env } = useEnvContext(); @@ -56,9 +65,15 @@ export default function GeneralPage() { const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - const [roleMappingMode, setRoleMappingMode] = useState< - "role" | "expression" - >("role"); + const [roleMappingMode, setRoleMappingMode] = + useState("fixedRoles"); + const [fixedRoleNames, setFixedRoleNames] = useState([]); + const [mappingBuilderClaimPath, setMappingBuilderClaimPath] = + useState("groups"); + const [mappingBuilderRules, setMappingBuilderRules] = useState< + MappingBuilderRule[] + >([createMappingBuilderRule()]); + const [rawRoleExpression, setRawRoleExpression] = useState(""); const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; @@ -190,34 +205,8 @@ export default function GeneralPage() { // Set the variant setVariant(idpVariant as "oidc" | "google" | "azure"); - // Check if roleMapping matches the basic pattern '{role name}' (simple single role) - // This should NOT match complex expressions like 'Admin' || 'Member' - const isBasicRolePattern = - roleMapping && - typeof roleMapping === "string" && - /^'[^']+'$/.test(roleMapping); - - // Determine if roleMapping is a number (roleId) or matches basic pattern - const isRoleId = - !isNaN(Number(roleMapping)) && roleMapping !== ""; - const isRoleName = isBasicRolePattern; - - // Extract role name from basic pattern for matching - let extractedRoleName = null; - if (isRoleName) { - extractedRoleName = roleMapping.slice(1, -1); // Remove quotes - } - - // Try to find matching role by name if we have a basic pattern - let matchingRoleId = undefined; - if (extractedRoleName && availableRoles.length > 0) { - const matchingRole = availableRoles.find( - (role) => role.name === extractedRoleName - ); - if (matchingRole) { - matchingRoleId = matchingRole.roleId; - } - } + const detectedRoleMappingConfig = + detectRoleMappingConfig(roleMapping); // Extract tenant ID from Azure URLs if present let tenantId = ""; @@ -238,9 +227,7 @@ export default function GeneralPage() { clientSecret: data.idpOidcConfig.clientSecret, autoProvision: data.idp.autoProvision, roleMapping: roleMapping || null, - roleId: isRoleId - ? Number(roleMapping) - : matchingRoleId || null + roleId: null }; // Add variant-specific fields @@ -259,10 +246,18 @@ export default function GeneralPage() { form.reset(formData); - // Set the role mapping mode based on the data - // Default to "expression" unless it's a simple roleId or basic '{role name}' pattern - setRoleMappingMode( - matchingRoleId && isRoleName ? "role" : "expression" + setRoleMappingMode(detectedRoleMappingConfig.mode); + setFixedRoleNames(detectedRoleMappingConfig.fixedRoleNames); + setMappingBuilderClaimPath( + detectedRoleMappingConfig.mappingBuilder.claimPath + ); + setMappingBuilderRules( + ensureMappingBuilderRuleIds( + detectedRoleMappingConfig.mappingBuilder.rules + ) + ); + setRawRoleExpression( + detectedRoleMappingConfig.rawExpression ); } } catch (e) { @@ -327,7 +322,26 @@ export default function GeneralPage() { return; } - const roleName = roles.find((r) => r.roleId === data.roleId)?.name; + const roleMappingExpression = compileRoleMappingExpression({ + mode: roleMappingMode, + fixedRoleNames, + mappingBuilder: { + claimPath: mappingBuilderClaimPath, + rules: mappingBuilderRules + }, + rawExpression: rawRoleExpression + }); + + if (data.autoProvision && !roleMappingExpression) { + toast({ + title: t("error"), + description: + "A role mapping is required when auto-provisioning is enabled.", + variant: "destructive" + }); + setLoading(false); + return; + } // Build payload based on variant let payload: any = { @@ -335,10 +349,7 @@ export default function GeneralPage() { clientId: data.clientId, clientSecret: data.clientSecret, autoProvision: data.autoProvision, - roleMapping: - roleMappingMode === "role" - ? `'${roleName}'` - : data.roleMapping || "" + roleMapping: roleMappingExpression }; // Add variant-specific fields @@ -438,16 +449,6 @@ export default function GeneralPage() { - - - - {t("redirectUrlAbout")} - - - {t("redirectUrlAboutDescription")} - - - {/* IDP Type Indicator */}
@@ -493,46 +494,47 @@ export default function GeneralPage() { {t("idpAutoProvisionUsers")} - {t("idpAutoProvisionUsersDescription")} + - - + -
- - { - form.setValue( - "autoProvision", - checked - ); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - // Clear roleId and roleMapping when mode changes - form.setValue("roleId", null); - form.setValue("roleMapping", null); - }} - roles={roles} - roleIdFieldName="roleId" - roleMappingFieldName="roleMapping" - /> - - -
+
+ + { + form.setValue("autoProvision", checked); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={setFixedRoleNames} + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={mappingBuilderRules} + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={setRawRoleExpression} + /> + +
@@ -832,29 +834,6 @@ export default function GeneralPage() { className="space-y-4" id="general-settings-form" > - - - - {t("idpJmespathAbout")} - - - {t( - "idpJmespathAboutDescription" - )}{" "} - - {t( - "idpJmespathAboutDescriptionLink" - )}{" "} - - - - - ([]); - const [roleMappingMode, setRoleMappingMode] = useState< - "role" | "expression" - >("role"); + const [roleMappingMode, setRoleMappingMode] = + useState("fixedRoles"); + const [fixedRoleNames, setFixedRoleNames] = useState([]); + const [mappingBuilderClaimPath, setMappingBuilderClaimPath] = + useState("groups"); + const [mappingBuilderRules, setMappingBuilderRules] = useState< + MappingBuilderRule[] + >([createMappingBuilderRule()]); + const [rawRoleExpression, setRawRoleExpression] = useState(""); const t = useTranslations(); const { isPaidUser } = usePaidStatus(); @@ -84,49 +96,6 @@ export default function Page() { type CreateIdpFormValues = z.infer; - interface ProviderTypeOption { - id: "oidc" | "google" | "azure"; - title: string; - description: string; - icon?: React.ReactNode; - } - - const providerTypes: ReadonlyArray = [ - { - id: "oidc", - title: "OAuth2/OIDC", - description: t("idpOidcDescription") - }, - { - id: "google", - title: t("idpGoogleTitle"), - description: t("idpGoogleDescription"), - icon: ( - {t("idpGoogleAlt")} - ) - }, - { - id: "azure", - title: t("idpAzureTitle"), - description: t("idpAzureDescription"), - icon: ( - {t("idpAzureAlt")} - ) - } - ]; - const form = useForm({ resolver: zodResolver(createIdpFormSchema), defaultValues: { @@ -174,47 +143,6 @@ export default function Page() { fetchRoles(); }, []); - // Handle provider type changes and set defaults - const handleProviderChange = (value: "oidc" | "google" | "azure") => { - form.setValue("type", value); - - if (value === "google") { - // Set Google defaults - form.setValue( - "authUrl", - "https://accounts.google.com/o/oauth2/v2/auth" - ); - form.setValue("tokenUrl", "https://oauth2.googleapis.com/token"); - form.setValue("identifierPath", "email"); - form.setValue("emailPath", "email"); - form.setValue("namePath", "name"); - form.setValue("scopes", "openid profile email"); - } else if (value === "azure") { - // Set Azure Entra ID defaults (URLs will be constructed dynamically) - form.setValue( - "authUrl", - "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize" - ); - form.setValue( - "tokenUrl", - "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token" - ); - form.setValue("identifierPath", "email"); - form.setValue("emailPath", "email"); - form.setValue("namePath", "name"); - form.setValue("scopes", "openid profile email"); - form.setValue("tenantId", ""); - } else { - // Reset to OIDC defaults - form.setValue("authUrl", ""); - form.setValue("tokenUrl", ""); - form.setValue("identifierPath", "sub"); - form.setValue("namePath", "name"); - form.setValue("emailPath", "email"); - form.setValue("scopes", "openid profile email"); - } - }; - async function onSubmit(data: CreateIdpFormValues) { setCreateLoading(true); @@ -228,7 +156,26 @@ export default function Page() { tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId); } - const roleName = roles.find((r) => r.roleId === data.roleId)?.name; + const roleMappingExpression = compileRoleMappingExpression({ + mode: roleMappingMode, + fixedRoleNames, + mappingBuilder: { + claimPath: mappingBuilderClaimPath, + rules: mappingBuilderRules + }, + rawExpression: rawRoleExpression + }); + + if (data.autoProvision && !roleMappingExpression) { + toast({ + title: t("error"), + description: + "A role mapping is required when auto-provisioning is enabled.", + variant: "destructive" + }); + setCreateLoading(false); + return; + } const payload = { name: data.name, @@ -240,10 +187,7 @@ export default function Page() { emailPath: data.emailPath, namePath: data.namePath, autoProvision: data.autoProvision, - roleMapping: - roleMappingMode === "role" - ? `'${roleName}'` - : data.roleMapping || "", + roleMapping: roleMappingExpression, scopes: data.scopes, variant: data.type }; @@ -308,23 +252,12 @@ export default function Page() { -
-
- - {t("idpType")} - -
- { - handleProviderChange( - value as "oidc" | "google" | "azure" - ); - }} - cols={3} - /> -
+ { + applyOidcIdpProviderType(form.setValue, next); + }} + />
@@ -364,47 +297,48 @@ export default function Page() { {t("idpAutoProvisionUsers")} - {t("idpAutoProvisionUsersDescription")} + - - - - - { - form.setValue( - "autoProvision", - checked - ); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - // Clear roleId and roleMapping when mode changes - form.setValue("roleId", null); - form.setValue("roleMapping", null); - }} - roles={roles} - roleIdFieldName="roleId" - roleMappingFieldName="roleMapping" - /> - - - + +
+ + { + form.setValue("autoProvision", checked); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={setFixedRoleNames} + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={mappingBuilderRules} + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={setRawRoleExpression} + /> + +
@@ -679,16 +613,6 @@ export default function Page() { /> - - - - - {t("idpOidcConfigureAlert")} - - - {t("idpOidcConfigureAlertDescription")} - -
diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx index b6ee14484..ae37c3752 100644 --- a/src/app/[orgId]/settings/access/invitations/page.tsx +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -3,13 +3,12 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import InvitationsTable, { InvitationRow -} from "../../../../../components/InvitationsTable"; +} from "@app/components/InvitationsTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; -import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; @@ -29,9 +28,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) { let invitations: { inviteId: string; email: string; - expiresAt: string; - roleId: number; - roleName?: string; + expiresAt: number; + roles: { roleId: number; roleName: string | null }[]; }[] = []; let hasInvitations = false; @@ -66,12 +64,15 @@ export default async function InvitationsPage(props: InvitationsPageProps) { } const invitationRows: InvitationRow[] = invitations.map((invite) => { + const names = invite.roles + .map((r) => r.roleName || t("accessRoleUnknown")) + .filter(Boolean); return { id: invite.inviteId, email: invite.email, expiresAt: new Date(Number(invite.expiresAt)).toISOString(), - role: invite.roleName || t("accessRoleUnknown"), - roleId: invite.roleId + roleLabels: names, + roleIds: invite.roles.map((r) => r.roleId) }; }); diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 6313d512a..9ab9e93fa 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -8,18 +8,10 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; import { Checkbox } from "@app/components/ui/checkbox"; +import OrgRolesTagField from "@app/components/OrgRolesTagField"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -44,34 +36,69 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; import { UserType } from "@server/types/UserTypes"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { build } from "@server/build"; + +const accessControlsFormSchema = z.object({ + username: z.string(), + autoProvisioned: z.boolean(), + roles: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ) +}); export default function AccessControlsPage() { - const { orgUser: user } = userOrgUserContext(); + const { orgUser: user, updateOrgUser } = userOrgUserContext(); + const { env } = useEnvContext(); - const api = createApiClient(useEnvContext()); + const api = createApiClient({ env }); const { orgId } = useParams(); const [loading, setLoading] = useState(false); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [activeRoleTagIndex, setActiveRoleTagIndex] = useState( + null + ); const t = useTranslations(); - - const formSchema = z.object({ - username: z.string(), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }), - autoProvisioned: z.boolean() - }); + const { isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.fullRbac); + const supportsMultipleRolesPerUser = isPaid; + const showMultiRolePaywallMessage = + !env.flags.disableEnterpriseFeatures && + ((build === "saas" && !isPaid) || + (build === "enterprise" && !isPaid) || + (build === "oss" && !isPaid)); const form = useForm({ - resolver: zodResolver(formSchema), + resolver: zodResolver(accessControlsFormSchema), defaultValues: { username: user.username!, - roleId: user.roleId?.toString(), - autoProvisioned: user.autoProvisioned || false + autoProvisioned: user.autoProvisioned || false, + roles: (user.roles ?? []).map((r) => ({ + id: r.roleId.toString(), + text: r.name + })) } }); + const currentRoleIds = user.roleIds ?? []; + + useEffect(() => { + form.setValue( + "roles", + (user.roles ?? []).map((r) => ({ + id: r.roleId.toString(), + text: r.name + })) + ); + }, [user.userId, currentRoleIds.join(",")]); + useEffect(() => { async function fetchRoles() { const res = await api @@ -94,32 +121,59 @@ export default function AccessControlsPage() { } fetchRoles(); - - form.setValue("roleId", user.roleId.toString()); form.setValue("autoProvisioned", user.autoProvisioned || false); }, []); - async function onSubmit(values: z.infer) { - setLoading(true); + const allRoleOptions = roles.map((role) => ({ + id: role.roleId.toString(), + text: role.name + })); + const paywallMessage = + build === "saas" + ? t("singleRolePerUserPlanNotice") + : t("singleRolePerUserEditionNotice"); + + async function onSubmit(values: z.infer) { + if (values.roles.length === 0) { + toast({ + variant: "destructive", + title: t("accessRoleErrorAdd"), + description: t("accessRoleSelectPlease") + }); + return; + } + + setLoading(true); try { - // Execute both API calls simultaneously - const [roleRes, userRes] = await Promise.all([ - api.post>( - `/role/${values.roleId}/add/${user.userId}` - ), + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + const updateRoleRequest = supportsMultipleRolesPerUser + ? api.post(`/user/${user.userId}/org/${orgId}/roles`, { + roleIds + }) + : api.post(`/role/${roleIds[0]}/add/${user.userId}`); + + await Promise.all([ + updateRoleRequest, api.post(`/org/${orgId}/user/${user.userId}`, { autoProvisioned: values.autoProvisioned }) ]); - if (roleRes.status === 200 && userRes.status === 200) { - toast({ - variant: "default", - title: t("userSaved"), - description: t("userSavedDescription") - }); - } + updateOrgUser({ + roleIds, + roles: values.roles.map((r) => ({ + roleId: parseInt(r.id, 10), + name: r.text + })), + autoProvisioned: values.autoProvisioned + }); + + toast({ + variant: "default", + title: t("userSaved"), + description: t("userSavedDescription") + }); } catch (e) { toast({ variant: "destructive", @@ -130,7 +184,6 @@ export default function AccessControlsPage() { ) }); } - setLoading(false); } @@ -154,7 +207,6 @@ export default function AccessControlsPage() { className="space-y-4" id="access-controls-form" > - {/* IDP Type Display */} {user.type !== UserType.Internal && user.idpType && (
@@ -171,48 +223,22 @@ export default function AccessControlsPage() {
)} - ( - - {t("role")} - - - - )} + {user.idpAutoProvision && ( diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 08737f5e2..0263d2b72 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -32,7 +32,7 @@ import { } from "@app/components/ui/select"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; +import { InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -49,6 +49,7 @@ import { build } from "@server/build"; import Image from "next/image"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import OrgRolesTagField from "@app/components/OrgRolesTagField"; type UserType = "internal" | "oidc"; @@ -76,7 +77,14 @@ export default function Page() { const api = createApiClient({ env }); const t = useTranslations(); - const { hasSaasSubscription } = usePaidStatus(); + const { hasSaasSubscription, isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.fullRbac); + const supportsMultipleRolesPerUser = isPaid; + const showMultiRolePaywallMessage = + !env.flags.disableEnterpriseFeatures && + ((build === "saas" && !isPaid) || + (build === "enterprise" && !isPaid) || + (build === "oss" && !isPaid)); const [selectedOption, setSelectedOption] = useState( "internal" @@ -89,19 +97,34 @@ export default function Page() { const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); const [userOptions, setUserOptions] = useState([]); const [dataLoaded, setDataLoaded] = useState(false); + const [activeInviteRoleTagIndex, setActiveInviteRoleTagIndex] = useState< + number | null + >(null); + const [activeOidcRoleTagIndex, setActiveOidcRoleTagIndex] = useState< + number | null + >(null); + + const roleTagsFieldSchema = z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .min(1, { message: t("accessRoleSelectPlease") }); const internalFormSchema = z.object({ email: z.email({ message: t("emailInvalid") }), validForHours: z .string() .min(1, { message: t("inviteValidityDuration") }), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) + roles: roleTagsFieldSchema }); const googleAzureFormSchema = z.object({ email: z.email({ message: t("emailInvalid") }), name: z.string().optional(), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) + roles: roleTagsFieldSchema }); const genericOidcFormSchema = z.object({ @@ -111,7 +134,7 @@ export default function Page() { .optional() .or(z.literal("")), name: z.string().optional(), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) + roles: roleTagsFieldSchema }); const formatIdpType = (type: string) => { @@ -166,12 +189,22 @@ export default function Page() { { hours: 168, name: t("day", { count: 7 }) } ]; + const allRoleOptions = roles.map((role) => ({ + id: role.roleId.toString(), + text: role.name + })); + + const invitePaywallMessage = + build === "saas" + ? t("singleRolePerUserPlanNotice") + : t("singleRolePerUserEditionNotice"); + const internalForm = useForm({ resolver: zodResolver(internalFormSchema), defaultValues: { email: "", validForHours: "72", - roleId: "" + roles: [] as { id: string; text: string }[] } }); @@ -180,7 +213,7 @@ export default function Page() { defaultValues: { email: "", name: "", - roleId: "" + roles: [] as { id: string; text: string }[] } }); @@ -190,7 +223,7 @@ export default function Page() { username: "", email: "", name: "", - roleId: "" + roles: [] as { id: string; text: string }[] } }); @@ -305,16 +338,17 @@ export default function Page() { ) { setLoading(true); - const res = await api - .post>( - `/org/${orgId}/create-invite`, - { - email: values.email, - roleId: parseInt(values.roleId), - validHours: parseInt(values.validForHours), - sendEmail: sendEmail - } as InviteUserBody - ) + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + + const res = await api.post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleIds, + validHours: parseInt(values.validForHours), + sendEmail + } + ) .catch((e) => { if (e.response?.status === 409) { toast({ @@ -358,6 +392,8 @@ export default function Page() { setLoading(true); + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + const res = await api .put(`/org/${orgId}/user`, { username: values.email, // Use email as username for Google/Azure @@ -365,7 +401,7 @@ export default function Page() { name: values.name, type: "oidc", idpId: selectedUserOption.idpId, - roleId: parseInt(values.roleId) + roleIds }) .catch((e) => { toast({ @@ -400,6 +436,8 @@ export default function Page() { setLoading(true); + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + const res = await api .put(`/org/${orgId}/user`, { username: values.username, @@ -407,7 +445,7 @@ export default function Page() { name: values.name, type: "oidc", idpId: selectedUserOption.idpId, - roleId: parseInt(values.roleId) + roleIds }) .catch((e) => { toast({ @@ -575,52 +613,32 @@ export default function Page() { )} /> - ( - - - {t("role")} - - - - + {env.email.emailEnabled && ( @@ -764,52 +782,32 @@ export default function Page() { )} /> - ( - - - {t("role")} - - - - + @@ -909,52 +907,32 @@ export default function Page() { )} /> - ( - - - {t("role")} - - - - + diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index c10363734..84685cc04 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -3,13 +3,12 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { ListUsersResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; -import UsersTable, { UserRow } from "../../../../../components/UsersTable"; +import UsersTable, { UserRow } from "@app/components/UsersTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; -import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; @@ -86,9 +85,14 @@ export default async function UsersPage(props: UsersPageProps) { idpId: user.idpId, idpName: user.idpName || t("idpNameInternal"), status: t("userConfirmed"), - role: user.isOwner - ? t("accessRoleOwner") - : user.roleName || t("accessRoleMember"), + roleLabels: user.isOwner + ? [t("accessRoleOwner")] + : (() => { + const names = (user.roles ?? []) + .map((r) => r.roleName) + .filter((n): n is string => Boolean(n?.length)); + return names.length ? names : [t("accessRoleMember")]; + })(), isOwner: user.isOwner || false }; }); diff --git a/src/app/[orgId]/settings/api-keys/page.tsx b/src/app/[orgId]/settings/api-keys/page.tsx index 2973bb542..0ed9553af 100644 --- a/src/app/[orgId]/settings/api-keys/page.tsx +++ b/src/app/[orgId]/settings/api-keys/page.tsx @@ -4,7 +4,7 @@ import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import OrgApiKeysTable, { OrgApiKeyRow -} from "../../../../components/OrgApiKeysTable"; +} from "@app/components/OrgApiKeysTable"; import { ListOrgApiKeysResponse } from "@server/routers/apiKeys"; import { getTranslations } from "next-intl/server"; diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index 04db84b38..d1325d32b 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -2,7 +2,7 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import DomainsTable, { DomainRow } from "../../../../components/DomainsTable"; +import DomainsTable, { DomainRow } from "@app/components/DomainsTable"; import { getTranslations } from "next-intl/server"; import { cache } from "react"; import { GetOrgResponse } from "@server/routers/org"; diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx index 2c51e9ecb..e7d0d85c8 100644 --- a/src/app/[orgId]/settings/general/security/page.tsx +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -79,7 +79,8 @@ const SecurityFormSchema = z.object({ passwordExpiryDays: z.number().nullable().optional(), settingsLogRetentionDaysRequest: z.number(), settingsLogRetentionDaysAccess: z.number(), - settingsLogRetentionDaysAction: z.number() + settingsLogRetentionDaysAction: z.number(), + settingsLogRetentionDaysConnection: z.number() }); const LOG_RETENTION_OPTIONS = [ @@ -120,7 +121,8 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { SecurityFormSchema.pick({ settingsLogRetentionDaysRequest: true, settingsLogRetentionDaysAccess: true, - settingsLogRetentionDaysAction: true + settingsLogRetentionDaysAction: true, + settingsLogRetentionDaysConnection: true }) ), defaultValues: { @@ -129,7 +131,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { settingsLogRetentionDaysAccess: org.settingsLogRetentionDaysAccess ?? 15, settingsLogRetentionDaysAction: - org.settingsLogRetentionDaysAction ?? 15 + org.settingsLogRetentionDaysAction ?? 15, + settingsLogRetentionDaysConnection: + org.settingsLogRetentionDaysConnection ?? 15 }, mode: "onChange" }); @@ -155,7 +159,9 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { settingsLogRetentionDaysAccess: data.settingsLogRetentionDaysAccess, settingsLogRetentionDaysAction: - data.settingsLogRetentionDaysAction + data.settingsLogRetentionDaysAction, + settingsLogRetentionDaysConnection: + data.settingsLogRetentionDaysConnection } as any; // Update organization @@ -473,6 +479,107 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { ); }} /> + { + const isDisabled = !isPaidUser( + tierMatrix.connectionLogs + ); + + return ( + + + {t( + "logRetentionConnectionLabel" + )} + + + + + + + ); + }} + /> )} diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index 810022b98..a0f1b5386 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -465,7 +465,11 @@ export default function GeneralPage() { cell: ({ row }) => { return ( + + ); + } + return ( + + {row.original.resourceName ?? "—"} + + ); + } + }, + { + accessorKey: "clientName", + header: ({ column }) => { + return ( +
+ {t("client")} + ({ + value: c.id.toString(), + label: c.name + }))} + selectedValue={filters.clientId} + onValueChange={(value) => + handleFilterChange("clientId", value) + } + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + const clientType = row.original.clientType === "olm" ? "machine" : "user"; + if (row.original.clientName && row.original.clientNiceId) { + return ( + + + + ); + } + return ( + + {row.original.clientName ?? "—"} + + ); + } + }, + { + accessorKey: "userEmail", + header: ({ column }) => { + return ( +
+ {t("user")} + ({ + value: u.id, + label: u.email || u.id + }))} + selectedValue={filters.userId} + onValueChange={(value) => + handleFilterChange("userId", value) + } + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + if (row.original.userEmail || row.original.userId) { + return ( + + + {row.original.userEmail ?? row.original.userId} + + ); + } + return ; + } + }, + { + accessorKey: "sourceAddr", + header: ({ column }) => { + return t("sourceAddress"); + }, + cell: ({ row }) => { + return ( + + {row.original.sourceAddr} + + ); + } + }, + { + accessorKey: "destAddr", + header: ({ column }) => { + return ( +
+ {t("destinationAddress")} + ({ + value: addr, + label: addr + }))} + selectedValue={filters.destAddr} + onValueChange={(value) => + handleFilterChange("destAddr", value) + } + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.destAddr} + + ); + } + }, + { + accessorKey: "duration", + header: ({ column }) => { + return t("duration"); + }, + cell: ({ row }) => { + return ( + + {formatDuration( + row.original.startedAt, + row.original.endedAt + )} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ {/*
+ Connection Details +
*/} +
+ Session ID:{" "} + + {row.sessionId ?? "—"} + +
+
+ Protocol:{" "} + {row.protocol?.toUpperCase() ?? "—"} +
+
+ Source:{" "} + + {row.sourceAddr ?? "—"} + +
+
+ Destination:{" "} + + {row.destAddr ?? "—"} + +
+
+
+ {/*
+ Resource & Site +
*/} + {/*
+ Resource:{" "} + {row.resourceName ?? "—"} + {row.resourceNiceId && ( + + ({row.resourceNiceId}) + + )} +
*/} +
+ Site: {row.siteName ?? "—"} + {row.siteNiceId && ( + + ({row.siteNiceId}) + + )} +
+
+ Site ID: {row.siteId ?? "—"} +
+
+ Started At:{" "} + {row.startedAt + ? new Date( + row.startedAt * 1000 + ).toLocaleString() + : "—"} +
+
+ Ended At:{" "} + {row.endedAt + ? new Date( + row.endedAt * 1000 + ).toLocaleString() + : "Active"} +
+
+ Duration:{" "} + {formatDuration(row.startedAt, row.endedAt)} +
+ {/*
+ Resource ID:{" "} + {row.siteResourceId ?? "—"} +
*/} +
+
+ {/*
+ Client & Transfer +
*/} + {/*
+ Bytes Sent (TX):{" "} + {formatBytes(row.bytesTx)} +
*/} + {/*
+ Bytes Received (RX):{" "} + {formatBytes(row.bytesRx)} +
*/} + {/*
+ Total Transfer:{" "} + {formatBytes( + (row.bytesTx ?? 0) + (row.bytesRx ?? 0) + )} +
*/} +
+
+
+ ); + }; + + return ( + <> + + + + + startTransition(exportData)} + isExporting={isExporting} + onDateRangeChange={handleDateRangeChange} + dateRange={{ + start: dateRange.startDate, + end: dateRange.endDate + }} + defaultSort={{ + id: "startedAt", + desc: true + }} + // Server-side pagination props + totalCount={totalCount} + currentPage={currentPage} + pageSize={pageSize} + onPageChange={handlePageChange} + onPageSizeChange={handlePageSizeChange} + isLoading={isLoading} + // Row expansion props + expandable={true} + renderExpandedRow={renderExpandedRow} + disabled={ + !isPaidUser(tierMatrix.connectionLogs) || build === "oss" + } + /> + + ); +} diff --git a/src/app/[orgId]/settings/logs/streaming/page.tsx b/src/app/[orgId]/settings/logs/streaming/page.tsx new file mode 100644 index 000000000..44ec39fcd --- /dev/null +++ b/src/app/[orgId]/settings/logs/streaming/page.tsx @@ -0,0 +1,481 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useParams } from "next/navigation"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { Switch } from "@app/components/ui/switch"; +import { Globe, MoreHorizontal, Plus } from "lucide-react"; +import { AxiosResponse } from "axios"; +import { build } from "@server/build"; +import Image from "next/image"; +import { StrategySelect, StrategyOption } from "@app/components/StrategySelect"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { + Destination, + HttpDestinationCredenza, + parseHttpConfig +} from "@app/components/HttpDestinationCredenza"; +import { useTranslations } from "next-intl"; + +// ── Re-export Destination so the rest of the file can use it ────────────────── + +interface ListDestinationsResponse { + destinations: Destination[]; + pagination: { + total: number; + limit: number; + offset: number; + }; +} + +// ── Destination card ─────────────────────────────────────────────────────────── + +interface DestinationCardProps { + destination: Destination; + onToggle: (id: number, enabled: boolean) => void; + onEdit: (destination: Destination) => void; + onDelete: (destination: Destination) => void; + isToggling: boolean; + disabled?: boolean; +} + +function DestinationCard({ + destination, + onToggle, + onEdit, + onDelete, + isToggling, + disabled = false +}: DestinationCardProps) { + const t = useTranslations(); + const cfg = parseHttpConfig(destination.config); + + return ( +
+ {/* Top row: icon + name/type + toggle */} +
+
+ {/* Squirkle icon: gray outer → white inner → black globe */} +
+
+ +
+
+
+

+ {cfg.name || t("streamingUnnamedDestination")} +

+

+ HTTP +

+
+
+ + onToggle(destination.destinationId, v) + } + disabled={isToggling || disabled} + className="shrink-0 mt-0.5" + /> +
+ + {/* URL preview */} +

+ {cfg.url || ( + {t("streamingNoUrlConfigured")} + )} +

+ + {/* Footer: edit button + three-dots menu */} +
+ + + + + + + onDelete(destination)} + > + {t("delete")} + + + +
+
+ ); +} + +// ── Add destination card ─────────────────────────────────────────────────────── + +function AddDestinationCard({ onClick }: { onClick: () => void }) { + const t = useTranslations(); + + return ( + + ); +} + +// ── Destination type picker ──────────────────────────────────────────────────── + +type DestinationType = "http" | "s3" | "datadog"; + +interface DestinationTypePickerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (type: DestinationType) => void; + isPaywalled?: boolean; +} + +function DestinationTypePicker({ + open, + onOpenChange, + onSelect, + isPaywalled = false +}: DestinationTypePickerProps) { + const t = useTranslations(); + const [selected, setSelected] = useState("http"); + + const destinationTypeOptions: ReadonlyArray> = [ + { + id: "http", + title: t("streamingHttpWebhookTitle"), + description: t("streamingHttpWebhookDescription"), + icon: + }, + { + id: "s3", + title: t("streamingS3Title"), + description: t("streamingS3Description"), + disabled: true, + icon: ( + {t("streamingS3Title")} + ) + }, + { + id: "datadog", + title: t("streamingDatadogTitle"), + description: t("streamingDatadogDescription"), + disabled: true, + icon: ( + {t("streamingDatadogTitle")} + ) + } + ]; + + useEffect(() => { + if (open) setSelected("http"); + }, [open]); + + return ( + + + + {t("streamingAddDestination")} + + {t("streamingTypePickerDescription")} + + + +
+ +
+
+ + + + + + +
+
+ ); +} + +// ── Main page ────────────────────────────────────────────────────────────────── + +export default function StreamingDestinationsPage() { + const { orgId } = useParams() as { orgId: string }; + const api = createApiClient(useEnvContext()); + const { isPaidUser } = usePaidStatus(); + const isEnterprise = isPaidUser(tierMatrix[TierFeature.SIEM]); + const t = useTranslations(); + + const [destinations, setDestinations] = useState([]); + const [loading, setLoading] = useState(true); + const [modalOpen, setModalOpen] = useState(false); + const [typePickerOpen, setTypePickerOpen] = useState(false); + const [editingDestination, setEditingDestination] = + useState(null); + const [togglingIds, setTogglingIds] = useState>(new Set()); + + // Delete state + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + + const loadDestinations = useCallback(async () => { + if (build == "oss") { + setDestinations([]); + setLoading(false); + return; + } + try { + const res = await api.get>( + `/org/${orgId}/event-streaming-destinations` + ); + setDestinations(res.data.data.destinations ?? []); + } catch (e) { + toast({ + variant: "destructive", + title: t("streamingFailedToLoad"), + description: formatAxiosError( + e, + t("streamingUnexpectedError") + ) + }); + } finally { + setLoading(false); + } + }, [orgId]); + + useEffect(() => { + loadDestinations(); + }, [loadDestinations]); + + const handleToggle = async (destinationId: number, enabled: boolean) => { + // Optimistic update + setDestinations((prev) => + prev.map((d) => + d.destinationId === destinationId ? { ...d, enabled } : d + ) + ); + setTogglingIds((prev) => new Set(prev).add(destinationId)); + + try { + await api.post( + `/org/${orgId}/event-streaming-destination/${destinationId}`, + { enabled } + ); + } catch (e) { + // Revert on failure + setDestinations((prev) => + prev.map((d) => + d.destinationId === destinationId + ? { ...d, enabled: !enabled } + : d + ) + ); + toast({ + variant: "destructive", + title: t("streamingFailedToUpdate"), + description: formatAxiosError( + e, + t("streamingUnexpectedError") + ) + }); + } finally { + setTogglingIds((prev) => { + const next = new Set(prev); + next.delete(destinationId); + return next; + }); + } + }; + + const handleDeleteCard = (destination: Destination) => { + setDeleteTarget(destination); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + await api.delete( + `/org/${orgId}/event-streaming-destination/${deleteTarget.destinationId}` + ); + toast({ title: t("streamingDeletedSuccess") }); + setDeleteDialogOpen(false); + setDeleteTarget(null); + loadDestinations(); + } catch (e) { + toast({ + variant: "destructive", + title: t("streamingFailedToDelete"), + description: formatAxiosError( + e, + t("streamingUnexpectedError") + ) + }); + } finally { + setDeleting(false); + } + }; + + const openCreate = () => { + setTypePickerOpen(true); + }; + + const handleTypePicked = (_type: DestinationType) => { + setTypePickerOpen(false); + setEditingDestination(null); + setModalOpen(true); + }; + + const openEdit = (destination: Destination) => { + setEditingDestination(destination); + setModalOpen(true); + }; + + return ( + <> + + + + + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {destinations.map((dest) => ( + + ))} + {/* Add card is always clickable — paywall is enforced inside the picker */} + +
+ )} + + + + + + {deleteTarget && ( + { + setDeleteDialogOpen(v); + if (!v) setDeleteTarget(null); + }} + string={ + parseHttpConfig(deleteTarget.config).name || t("streamingDeleteDialogThisDestination") + } + title={t("streamingDeleteTitle")} + dialog={ +

+ {t("streamingDeleteDialogAreYouSure")}{" "} + + {parseHttpConfig(deleteTarget.config).name || + t("streamingDeleteDialogThisDestination")} + + {t("streamingDeleteDialogPermanentlyRemoved")} +

+ } + buttonText={t("streamingDeleteButtonText")} + onConfirm={handleDeleteConfirm} + /> + )} + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/keys/page.tsx b/src/app/[orgId]/settings/provisioning/keys/page.tsx new file mode 100644 index 000000000..32a06706d --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/keys/page.tsx @@ -0,0 +1,84 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import SiteProvisioningKeysTable, { + SiteProvisioningKeyRow +} from "@app/components/SiteProvisioningKeysTable"; +import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; +import { getTranslations } from "next-intl/server"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import DismissableBanner from "@app/components/DismissableBanner"; +import Link from "next/link"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, Plug } from "lucide-react"; + +type ProvisioningKeysPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ProvisioningKeysPage( + props: ProvisioningKeysPageProps +) { + const params = await props.params; + const t = await getTranslations(); + + let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] = + []; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/site-provisioning-keys`, + await authCookieHeader() + ); + siteProvisioningKeys = res.data.data.siteProvisioningKeys; + } catch (e) {} + + const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({ + name: k.name, + id: k.siteProvisioningKeyId, + key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`, + createdAt: k.createdAt, + lastUsed: k.lastUsed, + maxBatchSize: k.maxBatchSize, + numUsed: k.numUsed, + validUntil: k.validUntil, + approveNewSites: k.approveNewSites + })); + + return ( + <> + } + description={t("provisioningKeysBannerDescription")} + > + + + + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/provisioning/layout.tsx b/src/app/[orgId]/settings/provisioning/layout.tsx new file mode 100644 index 000000000..bd2da7812 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/layout.tsx @@ -0,0 +1,38 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { getTranslations } from "next-intl/server"; + +interface ProvisioningLayoutProps { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +} + +export default async function ProvisioningLayout({ + children, + params +}: ProvisioningLayoutProps) { + const { orgId } = await params; + const t = await getTranslations(); + + const navItems = [ + { + title: t("provisioningKeys"), + href: `/${orgId}/settings/provisioning/keys` + }, + { + title: t("pendingSites"), + href: `/${orgId}/settings/provisioning/pending` + } + ]; + + return ( + <> + + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/page.tsx b/src/app/[orgId]/settings/provisioning/page.tsx new file mode 100644 index 000000000..51db66c2d --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +type ProvisioningPageProps = { + params: Promise<{ orgId: string }>; +}; + +export default async function ProvisioningPage(props: ProvisioningPageProps) { + const params = await props.params; + redirect(`/${params.orgId}/settings/provisioning/keys`); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx new file mode 100644 index 000000000..637f828b8 --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -0,0 +1,110 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { ListSitesResponse } from "@server/routers/site"; +import { AxiosResponse } from "axios"; +import { SiteRow } from "@app/components/SitesTable"; +import PendingSitesTable from "@app/components/PendingSitesTable"; +import { getTranslations } from "next-intl/server"; +import DismissableBanner from "@app/components/DismissableBanner"; +import Link from "next/link"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, Plug } from "lucide-react"; + +type PendingSitesPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise>; +}; + +export const dynamic = "force-dynamic"; + +export default async function PendingSitesPage(props: PendingSitesPageProps) { + const params = await props.params; + + const incomingSearchParams = new URLSearchParams(await props.searchParams); + incomingSearchParams.set("status", "pending"); + + let sites: ListSitesResponse["sites"] = []; + let pagination: ListSitesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; + + try { + const res = await internal.get>( + `/org/${params.orgId}/sites?${incomingSearchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + sites = responseData.sites; + pagination = responseData.pagination; + } catch (e) {} + + const t = await getTranslations(); + + function formatSize(mb: number, type: string): string { + if (type === "local") { + return "-"; + } + if (mb >= 1024 * 1024) { + return t("terabytes", { count: (mb / (1024 * 1024)).toFixed(2) }); + } else if (mb >= 1024) { + return t("gigabytes", { count: (mb / 1024).toFixed(2) }); + } else { + return t("megabytes", { count: mb.toFixed(2) }); + } + } + + const siteRows: SiteRow[] = sites.map((site) => ({ + name: site.name, + id: site.siteId, + nice: site.niceId.toString(), + address: site.address?.split("/")[0], + mbIn: formatSize(site.megabytesIn || 0, site.type), + mbOut: formatSize(site.megabytesOut || 0, site.type), + orgId: params.orgId, + type: site.type as any, + online: site.online, + newtVersion: site.newtVersion || undefined, + newtUpdateAvailable: site.newtUpdateAvailable || false, + exitNodeName: site.exitNodeName || undefined, + exitNodeEndpoint: site.exitNodeEndpoint || undefined, + remoteExitNodeId: (site as any).remoteExitNodeId || undefined + })); + + return ( + <> + } + description={t("pendingSitesBannerDescription")} + > + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index a533fb6c3..414a9b652 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -129,12 +129,13 @@ export default function ResourceAuthenticationPage() { orgId: org.org.orgId }) ); - const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( - orgQueries.identityProviders({ + const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery({ + ...orgQueries.identityProviders({ orgId: org.org.orgId, useOrgOnlyIdp: env.app.identityProviderMode === "org" - }) - ); + }), + enabled: isPaidUser(tierMatrix.orgOidc) + }); const pageLoading = isLoadingOrgRoles || diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 795c9adf2..3001491f1 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -124,20 +124,15 @@ export default function ReverseProxyTargetsPage(props: { resourceId: resource.resourceId }) ); - const { data: sites = [], isLoading: isLoadingSites } = useQuery( - orgQueries.sites({ - orgId: params.orgId - }) - ); - if (isLoadingSites || isLoadingTargets) { + if (isLoadingTargets) { return null; } return ( @@ -160,12 +155,12 @@ export default function ReverseProxyTargetsPage(props: { } function ProxyResourceTargetsForm({ - sites, + orgId, initialTargets, resource }: { initialTargets: LocalTarget[]; - sites: ListSitesResponse["sites"]; + orgId: string; resource: GetResourceResponse; }) { const t = useTranslations(); @@ -243,17 +238,21 @@ function ProxyResourceTargetsForm({ }); }, []); + const { data: sites = [] } = useQuery( + orgQueries.sites({ + orgId + }) + ); + const updateTarget = useCallback( (targetId: number, data: Partial) => { setTargets((prevTargets) => { - const site = sites.find((site) => site.siteId === data.siteId); return prevTargets.map((target) => target.targetId === targetId ? { ...target, ...data, - updated: true, - siteType: site ? site.type : target.siteType + updated: true } : target ); @@ -453,7 +452,7 @@ function ProxyResourceTargetsForm({ return ( 0 ? sites[0].siteId : 0, + siteName: sites.length > 0 ? sites[0].name : "", path: isHttp ? null : null, pathMatchType: isHttp ? null : null, rewritePath: isHttp ? null : null, diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx index 2b6a16870..60a219965 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx @@ -6,7 +6,9 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, + SelectGroup, SelectItem, + SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -76,6 +78,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { COUNTRIES } from "@server/db/countries"; import { MAJOR_ASNS } from "@server/db/asns"; +import { REGIONS, getRegionNameById, isValidRegionId } from "@server/db/regions"; import { Command, CommandEmpty, @@ -123,7 +126,10 @@ export default function ResourceRules(props: { const [countrySelectValue, setCountrySelectValue] = useState(""); const [openAddRuleCountrySelect, setOpenAddRuleCountrySelect] = useState(false); - const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = useState(false); + const [openAddRuleAsnSelect, setOpenAddRuleAsnSelect] = + useState(false); + const [openAddRuleRegionSelect, setOpenAddRuleRegionSelect] = + useState(false); const router = useRouter(); const t = useTranslations(); const { env } = useEnvContext(); @@ -143,14 +149,15 @@ export default function ResourceRules(props: { IP: "IP", CIDR: t("ipAddressRange"), COUNTRY: t("country"), - ASN: "ASN" + ASN: "ASN", + REGION: t("region") } as const; const addRuleForm = useForm({ resolver: zodResolver(addRuleSchema), defaultValues: { action: "ACCEPT", - match: "IP", + match: "PATH", value: "" } }); @@ -263,6 +270,20 @@ export default function ResourceRules(props: { setLoading(false); return; } + if ( + data.match === "REGION" && + !isValidRegionId(data.value) + ) { + toast({ + variant: "destructive", + title: t("rulesErrorInvalidRegion"), + description: + t("rulesErrorInvalidRegionDescription") || + "Invalid region." + }); + setLoading(false); + return; + } // find the highest priority and add one let priority = data.priority; @@ -316,6 +337,8 @@ export default function ResourceRules(props: { return t("rulesMatchCountry"); case "ASN": return "Enter an Autonomous System Number (e.g., AS15169 or 15169)"; + case "REGION": + return t("rulesMatchRegion"); } } @@ -541,16 +564,12 @@ export default function ResourceRules(props: { )} + {isMaxmindAvailable && ( + + { + RuleMatch.REGION + } + + )} {isMaxmindAsnAvailable && ( { @@ -1197,6 +1310,112 @@ export default function ResourceRules(props: {
+ ) : addRuleForm.watch( + "match" + ) === "REGION" ? ( + + + + + + + + + + {t( + "noRegionFound" + )} + + {REGIONS.map((continent) => ( + + { + field.onChange( + continent.id + ); + setOpenAddRuleRegionSelect( + false + ); + }} + > + + {t(continent.name)} ({continent.id}) + + {continent.includes.map((subregion) => ( + { + field.onChange( + subregion.id + ); + setOpenAddRuleRegionSelect( + false + ); + }} + > + + {t(subregion.name)} ({subregion.id}) + + ))} + + ))} + + + + ) : ( )} diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index fbc916479..b651a06bb 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -216,9 +216,7 @@ export default function Page() { const [remoteExitNodes, setRemoteExitNodes] = useState< ListRemoteExitNodesResponse["remoteExitNodes"] >([]); - const [loadingExitNodes, setLoadingExitNodes] = useState( - build === "saas" - ); + const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas"); const [createLoading, setCreateLoading] = useState(false); const [showSnippets, setShowSnippets] = useState(false); @@ -282,6 +280,7 @@ export default function Page() { method: isHttp ? "http" : null, port: 0, siteId: sites.length > 0 ? sites[0].siteId : 0, + siteName: sites.length > 0 ? sites[0].name : "", path: isHttp ? null : null, pathMatchType: isHttp ? null : null, rewritePath: isHttp ? null : null, @@ -336,8 +335,7 @@ export default function Page() { // In saas mode with no exit nodes, force HTTP const showTypeSelector = - build !== "saas" || - (!loadingExitNodes && remoteExitNodes.length > 0); + build !== "saas" || (!loadingExitNodes && remoteExitNodes.length > 0); const baseForm = useForm({ resolver: zodResolver(baseResourceFormSchema), @@ -600,7 +598,10 @@ export default function Page() { toast({ variant: "destructive", title: t("resourceErrorCreate"), - description: formatAxiosError(e, t("resourceErrorCreateMessageDescription")) + description: formatAxiosError( + e, + t("resourceErrorCreateMessageDescription") + ) }); } @@ -826,7 +827,8 @@ export default function Page() { cell: ({ row }) => ( + >(`/org/${orgId}/pick-site-defaults`); if (res && res.status === 200) { const data = res.data.data; setSiteDefaults(data); @@ -108,7 +111,7 @@ export default function CredentialsPage() { // generate config with the fetched data generatedWgConfig = generateWireGuardConfig( generatedKeypair.privateKey, - data.publicKey, + generatedKeypair.publicKey, data.subnet, data.address, data.endpoint, @@ -322,7 +325,7 @@ export default function CredentialsPage() { {!loadingDefaults && ( <> {wgConfig ? ( -
+
("oidc"); const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; const t = useTranslations(); - const GeneralFormSchema = z.object({ + const OidcFormSchema = z.object({ name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), clientId: z.string().min(1, { message: t("idpClientIdRequired") }), clientSecret: z @@ -72,10 +70,46 @@ export default function GeneralPage() { autoProvision: z.boolean().default(false) }); - type GeneralFormValues = z.infer; + const GoogleFormSchema = z.object({ + name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), + clientId: z.string().min(1, { message: t("idpClientIdRequired") }), + clientSecret: z + .string() + .min(1, { message: t("idpClientSecretRequired") }), + autoProvision: z.boolean().default(false) + }); - const form = useForm({ - resolver: zodResolver(GeneralFormSchema), + const AzureFormSchema = z.object({ + name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), + clientId: z.string().min(1, { message: t("idpClientIdRequired") }), + clientSecret: z + .string() + .min(1, { message: t("idpClientSecretRequired") }), + tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }), + autoProvision: z.boolean().default(false) + }); + + type OidcFormValues = z.infer; + type GoogleFormValues = z.infer; + type AzureFormValues = z.infer; + type GeneralFormValues = + | OidcFormValues + | GoogleFormValues + | AzureFormValues; + + const getFormSchema = () => { + switch (variant) { + case "google": + return GoogleFormSchema; + case "azure": + return AzureFormSchema; + default: + return OidcFormSchema; + } + }; + + const form = useForm({ + resolver: zodResolver(getFormSchema()) as never, defaultValues: { name: "", clientId: "", @@ -86,28 +120,60 @@ export default function GeneralPage() { emailPath: "email", namePath: "name", scopes: "openid profile email", - autoProvision: true + autoProvision: true, + tenantId: "" } }); + useEffect(() => { + form.clearErrors(); + }, [variant, form]); + useEffect(() => { const loadIdp = async () => { try { const res = await api.get(`/idp/${idpId}`); if (res.status === 200) { const data = res.data.data; - form.reset({ + const idpVariant = + (data.idpOidcConfig?.variant as + | "oidc" + | "google" + | "azure") || "oidc"; + setVariant(idpVariant); + + let tenantId = ""; + if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) { + const tenantMatch = data.idpOidcConfig.authUrl.match( + /login\.microsoftonline\.com\/([^/]+)\/oauth2/ + ); + if (tenantMatch) { + tenantId = tenantMatch[1]; + } + } + + const formData: Record = { name: data.idp.name, clientId: data.idpOidcConfig.clientId, clientSecret: data.idpOidcConfig.clientSecret, - authUrl: data.idpOidcConfig.authUrl, - tokenUrl: data.idpOidcConfig.tokenUrl, - identifierPath: data.idpOidcConfig.identifierPath, - emailPath: data.idpOidcConfig.emailPath, - namePath: data.idpOidcConfig.namePath, - scopes: data.idpOidcConfig.scopes, autoProvision: data.idp.autoProvision - }); + }; + + if (idpVariant === "oidc") { + formData.authUrl = data.idpOidcConfig.authUrl; + formData.tokenUrl = data.idpOidcConfig.tokenUrl; + formData.identifierPath = + data.idpOidcConfig.identifierPath; + formData.emailPath = + data.idpOidcConfig.emailPath ?? undefined; + formData.namePath = + data.idpOidcConfig.namePath ?? undefined; + formData.scopes = data.idpOidcConfig.scopes; + } else if (idpVariant === "azure") { + formData.tenantId = tenantId; + } + + form.reset(formData as GeneralFormValues); } } catch (e) { toast({ @@ -122,25 +188,76 @@ export default function GeneralPage() { }; loadIdp(); - }, [idpId, api, form, router]); + }, [idpId]); async function onSubmit(data: GeneralFormValues) { setLoading(true); try { - const payload = { + const schema = getFormSchema(); + const validationResult = schema.safeParse(data); + + if (!validationResult.success) { + const errors = validationResult.error.flatten().fieldErrors; + Object.keys(errors).forEach((key) => { + const fieldName = key as keyof GeneralFormValues; + const errorMessage = + (errors as Record)[ + key + ]?.[0] || t("invalidValue"); + form.setError(fieldName, { + type: "manual", + message: errorMessage + }); + }); + setLoading(false); + return; + } + + let payload: Record = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, - authUrl: data.authUrl, - tokenUrl: data.tokenUrl, - identifierPath: data.identifierPath, - emailPath: data.emailPath, - namePath: data.namePath, autoProvision: data.autoProvision, - scopes: data.scopes + variant }; + if (variant === "oidc") { + const oidcData = data as OidcFormValues; + payload = { + ...payload, + authUrl: oidcData.authUrl, + tokenUrl: oidcData.tokenUrl, + identifierPath: oidcData.identifierPath, + emailPath: oidcData.emailPath ?? "", + namePath: oidcData.namePath ?? "", + scopes: oidcData.scopes + }; + } else if (variant === "azure") { + const azureData = data as AzureFormValues; + const authUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/authorize`; + const tokenUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/token`; + payload = { + ...payload, + authUrl, + tokenUrl, + identifierPath: "email", + emailPath: "email", + namePath: "name", + scopes: "openid profile email" + }; + } else if (variant === "google") { + payload = { + ...payload, + authUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + identifierPath: "email", + emailPath: "email", + namePath: "name", + scopes: "openid profile email" + }; + } + const res = await api.post(`/idp/${idpId}/oidc`, payload); if (res.status === 200) { @@ -189,15 +306,13 @@ export default function GeneralPage() { - - - - {t("redirectUrlAbout")} - - - {t("redirectUrlAboutDescription")} - - +
+ + {t("idpTypeLabel")}: + + +
+
)} /> - -
- { - form.setValue( - "autoProvision", - checked - ); - }} - /> -
- - {t("idpAutoProvisionUsersDescription")} -
- + + + + {t("idpAutoProvisionUsers")} + + + + + + +
+ +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> +
+
+ {form.watch("autoProvision") && ( + + {t.rich( + "idpAdminAutoProvisionPoliciesTabHint", + { + policiesTabLink: ( + chunks + ) => ( + + {chunks} + + ) + } + )} + + )} +
+
+ +
+
+ + {variant === "google" && ( - {t("idpOidcConfigure")} + {t("idpGoogleConfiguration")} - {t("idpOidcConfigureDescription")} + {t("idpGoogleConfigurationDescription")} @@ -279,7 +432,7 @@ export default function GeneralPage() { {t( - "idpClientIdDescription" + "idpGoogleClientIdDescription" )} @@ -303,49 +456,7 @@ export default function GeneralPage() { {t( - "idpClientSecretDescription" - )} - - - - )} - /> - - ( - - - {t("idpAuthUrl")} - - - - - - {t( - "idpAuthUrlDescription" - )} - - - - )} - /> - - ( - - - {t("idpTokenUrl")} - - - - - - {t( - "idpTokenUrlDescription" + "idpGoogleClientSecretDescription" )} @@ -357,14 +468,16 @@ export default function GeneralPage() { + )} + {variant === "azure" && ( - {t("idpToken")} + {t("idpAzureConfiguration")} - {t("idpTokenDescription")} + {t("idpAzureConfigurationDescription")} @@ -375,43 +488,20 @@ export default function GeneralPage() { className="space-y-4" id="general-settings-form" > - - - - {t("idpJmespathAbout")} - - - {t( - "idpJmespathAboutDescription" - )} - - {t( - "idpJmespathAboutDescriptionLink" - )}{" "} - - - - - ( - {t("idpJmespathLabel")} + {t("idpTenantId")} {t( - "idpJmespathLabelDescription" + "idpAzureTenantIdDescription" )} @@ -421,20 +511,18 @@ export default function GeneralPage() { ( - {t( - "idpJmespathEmailPathOptional" - )} + {t("idpClientId")} {t( - "idpJmespathEmailPathOptionalDescription" + "idpAzureClientIdDescription" )} @@ -444,43 +532,21 @@ export default function GeneralPage() { ( - {t( - "idpJmespathNamePathOptional" - )} + {t("idpClientSecret")} - + {t( - "idpJmespathNamePathOptionalDescription" - )} - - - - )} - /> - - ( - - - {t( - "idpOidcConfigureScopes" - )} - - - - - - {t( - "idpOidcConfigureScopesDescription" + "idpAzureClientSecretDescription" )} @@ -492,15 +558,263 @@ export default function GeneralPage() { -
+ )} + + {variant === "oidc" && ( + + + + + {t("idpOidcConfigure")} + + + {t("idpOidcConfigureDescription")} + + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpClientIdDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpClientSecretDescription" + )} + + + + )} + /> + + ( + + + {t("idpAuthUrl")} + + + + + + {t( + "idpAuthUrlDescription" + )} + + + + )} + /> + + ( + + + {t("idpTokenUrl")} + + + + + + {t( + "idpTokenUrlDescription" + )} + + + + )} + /> + + +
+
+
+ + + + + {t("idpToken")} + + + {t("idpTokenDescription")} + + + + +
+ + ( + + + {t( + "idpJmespathLabel" + )} + + + + + + {t( + "idpJmespathLabelDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathEmailPathOptional" + )} + + + + + + {t( + "idpJmespathEmailPathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathNamePathOptional" + )} + + + + + + {t( + "idpJmespathNamePathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpOidcConfigureScopes" + )} + + + + + + {t( + "idpOidcConfigureScopesDescription" + )} + + + + )} + /> + + +
+
+
+
+ )}
diff --git a/src/app/admin/idp/[idpId]/layout.tsx b/src/app/admin/idp/[idpId]/layout.tsx index 9634a3de2..93ca08bd7 100644 --- a/src/app/admin/idp/[idpId]/layout.tsx +++ b/src/app/admin/idp/[idpId]/layout.tsx @@ -34,7 +34,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { href: `/admin/idp/${params.idpId}/general` }, { - title: t("orgPolicies"), + title: t("autoProvisionSettings"), href: `/admin/idp/${params.idpId}/policies` } ]; diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx index bf17abe98..60e8a094a 100644 --- a/src/app/admin/idp/[idpId]/policies/page.tsx +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; @@ -31,9 +31,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react"; -import PolicyTable, { PolicyRow } from "../../../../../components/PolicyTable"; +import PolicyTable, { PolicyRow } from "@app/components/PolicyTable"; import { AxiosResponse } from "axios"; import { ListOrgsResponse } from "@server/routers/org"; +import { ListRolesResponse } from "@server/routers/role"; import { Popover, PopoverContent, @@ -50,8 +51,6 @@ import { } from "@app/components/ui/command"; import { CaretSortIcon } from "@radix-ui/react-icons"; import Link from "next/link"; -import { Textarea } from "@app/components/ui/textarea"; -import { InfoPopup } from "@app/components/ui/info-popup"; import { GetIdpResponse } from "@server/routers/idp"; import { SettingsContainer, @@ -64,16 +63,40 @@ import { SettingsSectionForm } from "@app/components/Settings"; import { useTranslations } from "next-intl"; +import RoleMappingConfigFields from "@app/components/RoleMappingConfigFields"; +import { + compileRoleMappingExpression, + createMappingBuilderRule, + defaultRoleMappingConfig, + detectRoleMappingConfig, + MappingBuilderRule, + RoleMappingMode +} from "@app/lib/idpRoleMapping"; type Organization = { orgId: string; name: string; }; +function resetRoleMappingStateFromDetected( + setMode: (m: RoleMappingMode) => void, + setFixed: (v: string[]) => void, + setClaim: (v: string) => void, + setRules: (v: MappingBuilderRule[]) => void, + setRaw: (v: string) => void, + stored: string | null | undefined +) { + const d = detectRoleMappingConfig(stored); + setMode(d.mode); + setFixed(d.fixedRoleNames); + setClaim(d.mappingBuilder.claimPath); + setRules(d.mappingBuilder.rules); + setRaw(d.rawExpression); +} + export default function PoliciesPage() { const { env } = useEnvContext(); const api = createApiClient({ env }); - const router = useRouter(); const { idpId } = useParams(); const t = useTranslations(); @@ -88,14 +111,39 @@ export default function PoliciesPage() { const [showAddDialog, setShowAddDialog] = useState(false); const [editingPolicy, setEditingPolicy] = useState(null); + const [defaultRoleMappingMode, setDefaultRoleMappingMode] = + useState("fixedRoles"); + const [defaultFixedRoleNames, setDefaultFixedRoleNames] = useState< + string[] + >([]); + const [defaultMappingBuilderClaimPath, setDefaultMappingBuilderClaimPath] = + useState("groups"); + const [defaultMappingBuilderRules, setDefaultMappingBuilderRules] = + useState([createMappingBuilderRule()]); + const [defaultRawRoleExpression, setDefaultRawRoleExpression] = + useState(""); + + const [policyRoleMappingMode, setPolicyRoleMappingMode] = + useState("fixedRoles"); + const [policyFixedRoleNames, setPolicyFixedRoleNames] = useState( + [] + ); + const [policyMappingBuilderClaimPath, setPolicyMappingBuilderClaimPath] = + useState("groups"); + const [policyMappingBuilderRules, setPolicyMappingBuilderRules] = useState< + MappingBuilderRule[] + >([createMappingBuilderRule()]); + const [policyRawRoleExpression, setPolicyRawRoleExpression] = useState(""); + const [policyOrgRoles, setPolicyOrgRoles] = useState< + { roleId: number; name: string }[] + >([]); + const policyFormSchema = z.object({ orgId: z.string().min(1, { message: t("orgRequired") }), - roleMapping: z.string().optional(), orgMapping: z.string().optional() }); const defaultMappingsSchema = z.object({ - defaultRoleMapping: z.string().optional(), defaultOrgMapping: z.string().optional() }); @@ -106,15 +154,15 @@ export default function PoliciesPage() { resolver: zodResolver(policyFormSchema), defaultValues: { orgId: "", - roleMapping: "", orgMapping: "" } }); + const policyFormOrgId = form.watch("orgId"); + const defaultMappingsForm = useForm({ resolver: zodResolver(defaultMappingsSchema), defaultValues: { - defaultRoleMapping: "", defaultOrgMapping: "" } }); @@ -127,9 +175,16 @@ export default function PoliciesPage() { if (res.status === 200) { const data = res.data.data; defaultMappingsForm.reset({ - defaultRoleMapping: data.idp.defaultRoleMapping || "", defaultOrgMapping: data.idp.defaultOrgMapping || "" }); + resetRoleMappingStateFromDetected( + setDefaultRoleMappingMode, + setDefaultFixedRoleNames, + setDefaultMappingBuilderClaimPath, + setDefaultMappingBuilderRules, + setDefaultRawRoleExpression, + data.idp.defaultRoleMapping + ); } } catch (e) { toast({ @@ -184,11 +239,67 @@ export default function PoliciesPage() { load(); }, [idpId]); + useEffect(() => { + if (!showAddDialog) { + return; + } + + const orgId = editingPolicy?.orgId || policyFormOrgId; + if (!orgId) { + setPolicyOrgRoles([]); + return; + } + + let cancelled = false; + (async () => { + const res = await api + .get>(`/org/${orgId}/roles`) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: t("accessRoleErrorFetch"), + description: formatAxiosError( + e, + t("accessRoleErrorFetchDescription") + ) + }); + return null; + }); + if (!cancelled && res?.status === 200) { + setPolicyOrgRoles(res.data.data.roles); + } + })(); + + return () => { + cancelled = true; + }; + }, [showAddDialog, editingPolicy?.orgId, policyFormOrgId, api, t]); + + function resetPolicyDialogRoleMappingState() { + const d = defaultRoleMappingConfig(); + setPolicyRoleMappingMode(d.mode); + setPolicyFixedRoleNames(d.fixedRoleNames); + setPolicyMappingBuilderClaimPath(d.mappingBuilder.claimPath); + setPolicyMappingBuilderRules(d.mappingBuilder.rules); + setPolicyRawRoleExpression(d.rawExpression); + } + const onAddPolicy = async (data: PolicyFormValues) => { + const roleMappingExpression = compileRoleMappingExpression({ + mode: policyRoleMappingMode, + fixedRoleNames: policyFixedRoleNames, + mappingBuilder: { + claimPath: policyMappingBuilderClaimPath, + rules: policyMappingBuilderRules + }, + rawExpression: policyRawRoleExpression + }); + setAddPolicyLoading(true); try { const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, { - roleMapping: data.roleMapping, + roleMapping: roleMappingExpression, orgMapping: data.orgMapping }); if (res.status === 201) { @@ -197,7 +308,7 @@ export default function PoliciesPage() { name: organizations.find((org) => org.orgId === data.orgId) ?.name || "", - roleMapping: data.roleMapping, + roleMapping: roleMappingExpression, orgMapping: data.orgMapping }; setPolicies([...policies, newPolicy]); @@ -207,6 +318,7 @@ export default function PoliciesPage() { }); setShowAddDialog(false); form.reset(); + resetPolicyDialogRoleMappingState(); } } catch (e) { toast({ @@ -222,12 +334,22 @@ export default function PoliciesPage() { const onEditPolicy = async (data: PolicyFormValues) => { if (!editingPolicy) return; + const roleMappingExpression = compileRoleMappingExpression({ + mode: policyRoleMappingMode, + fixedRoleNames: policyFixedRoleNames, + mappingBuilder: { + claimPath: policyMappingBuilderClaimPath, + rules: policyMappingBuilderRules + }, + rawExpression: policyRawRoleExpression + }); + setEditPolicyLoading(true); try { const res = await api.post( `/idp/${idpId}/org/${editingPolicy.orgId}`, { - roleMapping: data.roleMapping, + roleMapping: roleMappingExpression, orgMapping: data.orgMapping } ); @@ -237,7 +359,7 @@ export default function PoliciesPage() { policy.orgId === editingPolicy.orgId ? { ...policy, - roleMapping: data.roleMapping, + roleMapping: roleMappingExpression, orgMapping: data.orgMapping } : policy @@ -250,6 +372,7 @@ export default function PoliciesPage() { setShowAddDialog(false); setEditingPolicy(null); form.reset(); + resetPolicyDialogRoleMappingState(); } } catch (e) { toast({ @@ -287,10 +410,20 @@ export default function PoliciesPage() { }; const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => { + const defaultRoleMappingExpression = compileRoleMappingExpression({ + mode: defaultRoleMappingMode, + fixedRoleNames: defaultFixedRoleNames, + mappingBuilder: { + claimPath: defaultMappingBuilderClaimPath, + rules: defaultMappingBuilderRules + }, + rawExpression: defaultRawRoleExpression + }); + setUpdateDefaultMappingsLoading(true); try { const res = await api.post(`/idp/${idpId}/oidc`, { - defaultRoleMapping: data.defaultRoleMapping, + defaultRoleMapping: defaultRoleMappingExpression, defaultOrgMapping: data.defaultOrgMapping }); if (res.status === 200) { @@ -317,25 +450,36 @@ export default function PoliciesPage() { return ( <> - - - - {t("orgPoliciesAbout")} - - - {/*TODO(vlalx): Validate replacing */} - {t("orgPoliciesAboutDescription")}{" "} - - {t("orgPoliciesAboutDescriptionLink")} - - - - + { + loadOrganizations(); + form.reset({ + orgId: "", + orgMapping: "" + }); + setEditingPolicy(null); + resetPolicyDialogRoleMappingState(); + setShowAddDialog(true); + }} + onEdit={(policy) => { + setEditingPolicy(policy); + form.reset({ + orgId: policy.orgId, + orgMapping: policy.orgMapping || "" + }); + resetRoleMappingStateFromDetected( + setPolicyRoleMappingMode, + setPolicyFixedRoleNames, + setPolicyMappingBuilderClaimPath, + setPolicyMappingBuilderRules, + setPolicyRawRoleExpression, + policy.roleMapping + ); + setShowAddDialog(true); + }} + /> @@ -353,51 +497,58 @@ export default function PoliciesPage() { onUpdateDefaultMappings )} id="policy-default-mappings-form" - className="space-y-4" + className="space-y-6" > -
- ( - - - {t("defaultMappingsRole")} - - - - - - {t( - "defaultMappingsRoleDescription" - )} - - - - )} - /> + - ( - - - {t("defaultMappingsOrg")} - - - - - - {t( - "defaultMappingsOrgDescription" - )} - - - - )} - /> -
+ ( + + + {t("defaultMappingsOrg")} + + + + + + {t( + "defaultMappingsOrgDescription" + )} + + + + )} + /> @@ -411,41 +562,20 @@ export default function PoliciesPage() {
- - { - loadOrganizations(); - form.reset({ - orgId: "", - roleMapping: "", - orgMapping: "" - }); - setEditingPolicy(null); - setShowAddDialog(true); - }} - onEdit={(policy) => { - setEditingPolicy(policy); - form.reset({ - orgId: policy.orgId, - roleMapping: policy.roleMapping || "", - orgMapping: policy.orgMapping || "" - }); - setShowAddDialog(true); - }} - />
{ setShowAddDialog(val); - setEditingPolicy(null); - form.reset(); + if (!val) { + setEditingPolicy(null); + form.reset(); + resetPolicyDialogRoleMappingState(); + } }} > - + {editingPolicy @@ -456,7 +586,7 @@ export default function PoliciesPage() { {t("orgPolicyConfig")} - +
- ( - - - {t("roleMappingPathOptional")} - - - - - - {t( - "defaultMappingsRoleDescription" - )} - - - - )} + ; - interface ProviderTypeOption { - id: "oidc"; - title: string; - description: string; - } - - const providerTypes: ReadonlyArray = [ - { - id: "oidc", - title: "OAuth2/OIDC", - description: t("idpOidcDescription") - } - ]; - const form = useForm({ resolver: zodResolver(createIdpFormSchema), defaultValues: { name: "", - type: "oidc", + type: "oidc" as const, clientId: "", clientSecret: "", authUrl: "", @@ -92,25 +88,46 @@ export default function Page() { namePath: "name", emailPath: "email", scopes: "openid profile email", + tenantId: "", autoProvision: false } }); + const watchedType = form.watch("type"); + const templatesLocked = + !templatesPaid && (watchedType === "google" || watchedType === "azure"); + async function onSubmit(data: CreateIdpFormValues) { + if ( + !templatesPaid && + (data.type === "google" || data.type === "azure") + ) { + return; + } + setCreateLoading(true); try { + let authUrl = data.authUrl; + let tokenUrl = data.tokenUrl; + + if (data.type === "azure" && data.tenantId) { + authUrl = authUrl?.replace("{{TENANT_ID}}", data.tenantId); + tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId); + } + const payload = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, - authUrl: data.authUrl, - tokenUrl: data.tokenUrl, + authUrl: authUrl, + tokenUrl: tokenUrl, identifierPath: data.identifierPath, emailPath: data.emailPath, namePath: data.namePath, autoProvision: data.autoProvision, - scopes: data.scopes + scopes: data.scopes, + variant: data.type }; const res = await api.put("/idp/oidc", payload); @@ -161,332 +178,480 @@ export default function Page() { - - - - ( - - - {t("name")} - - - - - - {t("idpDisplayName")} - - - - )} - /> + {templatesLocked ? ( +
+ +
+ ) : null} + { + applyOidcIdpProviderType(form.setValue, next); + }} + /> -
- + + + + ( + + + {t("name")} + + + + + + {t("idpDisplayName")} + + + )} - onCheckedChange={(checked) => { - form.setValue( - "autoProvision", - checked - ); - }} /> -
- - {t("idpAutoProvisionUsersDescription")} - - - -
- {/*
*/} - {/*
*/} - {/* */} - {/* {t("idpType")} */} - {/* */} - {/*
*/} - {/* */} - {/* { */} - {/* form.setValue("type", value as "oidc"); */} - {/* }} */} - {/* cols={3} */} - {/* /> */} - {/*
*/} +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> +
+ + + +
- {form.watch("type") === "oidc" && ( - +
+ {watchedType === "google" && ( - {t("idpOidcConfigure")} + {t("idpGoogleConfigurationTitle")} - {t("idpOidcConfigureDescription")} + {t("idpGoogleConfigurationDescription")} -
- - ( - - - {t("idpClientId")} - - - - - - {t( - "idpClientIdDescription" - )} - - - + + + + > + ( + + + {t("idpClientId")} + + + + + + {t( + "idpGoogleClientIdDescription" + )} + + + + )} + /> - ( - - - {t("idpClientSecret")} - - - - - - {t( - "idpClientSecretDescription" - )} - - - - )} - /> - - ( - - - {t("idpAuthUrl")} - - - - - - {t( - "idpAuthUrlDescription" - )} - - - - )} - /> - - ( - - - {t("idpTokenUrl")} - - - - - - {t( - "idpTokenUrlDescription" - )} - - - - )} - /> - - - - - - - {t("idpOidcConfigureAlert")} - - - {t("idpOidcConfigureAlertDescription")} - - + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpGoogleClientSecretDescription" + )} + + + + )} + /> + + +
+ )} + {watchedType === "azure" && ( - {t("idpToken")} + {t("idpAzureConfigurationTitle")} - {t("idpTokenDescription")} + {t("idpAzureConfigurationDescription")} -
- - - - - {t("idpJmespathAbout")} - - - {t( - "idpJmespathAboutDescription" - )}{" "} - - {t( - "idpJmespathAboutDescriptionLink" - )}{" "} - - - - - - ( - - - {t("idpJmespathLabel")} - - - - - - {t( - "idpJmespathLabelDescription" - )} - - - + + + + > + ( + + + {t( + "idpTenantIdLabel" + )} + + + + + + {t( + "idpAzureTenantIdDescription" + )} + + + + )} + /> - ( - - - {t( - "idpJmespathEmailPathOptional" - )} - - - - - - {t( - "idpJmespathEmailPathOptionalDescription" - )} - - - - )} - /> + ( + + + {t("idpClientId")} + + + + + + {t( + "idpAzureClientIdDescription2" + )} + + + + )} + /> - ( - - - {t( - "idpJmespathNamePathOptional" - )} - - - - - - {t( - "idpJmespathNamePathOptionalDescription" - )} - - - - )} - /> - - ( - - - {t( - "idpOidcConfigureScopes" - )} - - - - - - {t( - "idpOidcConfigureScopesDescription" - )} - - - - )} - /> - - + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpAzureClientSecretDescription2" + )} + + + + )} + /> + + +
- - )} + )} + + {watchedType === "oidc" && ( + + + + + {t("idpOidcConfigure")} + + + {t("idpOidcConfigureDescription")} + + + +
+ + ( + + + {t("idpClientId")} + + + + + + {t( + "idpClientIdDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpClientSecret" + )} + + + + + + {t( + "idpClientSecretDescription" + )} + + + + )} + /> + + ( + + + {t("idpAuthUrl")} + + + + + + {t( + "idpAuthUrlDescription" + )} + + + + )} + /> + + ( + + + {t("idpTokenUrl")} + + + + + + {t( + "idpTokenUrlDescription" + )} + + + + )} + /> + + +
+
+ + + + + {t("idpToken")} + + + {t("idpTokenDescription")} + + + +
+ + ( + + + {t( + "idpJmespathLabel" + )} + + + + + + {t( + "idpJmespathLabelDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathEmailPathOptional" + )} + + + + + + {t( + "idpJmespathEmailPathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpJmespathNamePathOptional" + )} + + + + + + {t( + "idpJmespathNamePathOptionalDescription" + )} + + + + )} + /> + + ( + + + {t( + "idpOidcConfigureScopes" + )} + + + + + + {t( + "idpOidcConfigureScopesDescription" + )} + + + + )} + /> + + +
+
+
+ )} +
@@ -501,7 +666,7 @@ export default function Page() {
{autoProvision && ( -
-
- - {t("roleMapping")} - - - {t("roleMappingDescription")} - - - -
- - -
-
- - -
-
-
- - {roleMappingMode === "role" ? ( - ( - - - - {t("selectRoleDescription")} - - - - )} - /> - ) : ( - ( - - - - - - {t("roleMappingExpressionDescription")} - - - - )} - /> - )} -
+ )}
); diff --git a/src/components/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx index 2f6f9aff2..d0e26a1c2 100644 --- a/src/components/CreateShareLinkForm.tsx +++ b/src/components/CreateShareLinkForm.tsx @@ -69,6 +69,7 @@ import { import AccessTokenSection from "@app/components/AccessTokenUsage"; import { useTranslations } from "next-intl"; import { toUnicode } from "punycode"; +import { ResourceSelector, type SelectedResource } from "./resource-selector"; type FormProps = { open: boolean; @@ -99,18 +100,21 @@ export default function CreateShareLinkForm({ orgQueries.resources({ orgId: org?.org.orgId ?? "" }) ); - const resources = useMemo( - () => - allResources - .filter((r) => r.http) - .map((r) => ({ - resourceId: r.resourceId, - name: r.name, - niceId: r.niceId, - resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` - })), - [allResources] - ); + const [selectedResource, setSelectedResource] = + useState(null); + + // const resources = useMemo( + // () => + // allResources + // .filter((r) => r.http) + // .map((r) => ({ + // resourceId: r.resourceId, + // name: r.name, + // niceId: r.niceId, + // resourceUrl: `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` + // })), + // [allResources] + // ); const formSchema = z.object({ resourceId: z.number({ message: t("shareErrorSelectResource") }), @@ -199,15 +203,11 @@ export default function CreateShareLinkForm({ setAccessToken(token.accessToken); setAccessTokenId(token.accessTokenId); - const resource = resources.find( - (r) => r.resourceId === values.resourceId - ); - onCreated?.({ accessTokenId: token.accessTokenId, resourceId: token.resourceId, resourceName: values.resourceName, - resourceNiceId: resource ? resource.niceId : "", + resourceNiceId: selectedResource ? selectedResource.niceId : "", title: token.title, createdAt: token.createdAt, expiresAt: token.expiresAt @@ -217,11 +217,6 @@ export default function CreateShareLinkForm({ setLoading(false); } - function getSelectedResourceName(id: number) { - const resource = resources.find((r) => r.resourceId === id); - return `${resource?.name}`; - } - return ( <> -
+
{!link && (
- {field.value - ? getSelectedResourceName( - field.value - ) + {selectedResource?.name + ? selectedResource.name : t( "resourceSelect" )} @@ -281,59 +274,34 @@ export default function CreateShareLinkForm({ - - - - - {t( - "resourcesNotFound" - )} - - - {resources.map( - ( - r - ) => ( - { - form.setValue( - "resourceId", - r.resourceId - ); - form.setValue( - "resourceName", - r.name - ); - form.setValue( - "resourceUrl", - r.resourceUrl - ); - }} - > - - {`${r.name}`} - - ) - )} - - - + { + form.setValue( + "resourceId", + r.resourceId + ); + form.setValue( + "resourceName", + r.name + ); + form.setValue( + "resourceUrl", + `${r.ssl ? "https://" : "http://"}${toUnicode(r.fullDomain || "")}/` + ); + setSelectedResource( + r + ); + }} + /> diff --git a/src/components/CreateSiteProvisioningKeyCredenza.tsx b/src/components/CreateSiteProvisioningKeyCredenza.tsx new file mode 100644 index 000000000..f6b80a964 --- /dev/null +++ b/src/components/CreateSiteProvisioningKeyCredenza.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { Input } from "@app/components/ui/input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; +import { AxiosResponse } from "axios"; +import { InfoIcon } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { + DateTimePicker, + DateTimeValue +} from "@app/components/DateTimePicker"; + +const FORM_ID = "create-site-provisioning-key-form"; + +type CreateSiteProvisioningKeyCredenzaProps = { + open: boolean; + setOpen: (open: boolean) => void; + orgId: string; +}; + +export default function CreateSiteProvisioningKeyCredenza({ + open, + setOpen, + orgId +}: CreateSiteProvisioningKeyCredenzaProps) { + const t = useTranslations(); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + const [created, setCreated] = + useState(null); + + const createFormSchema = z + .object({ + name: z + .string() + .min(1, { + message: t("nameMin", { len: 1 }) + }) + .max(255, { + message: t("nameMax", { len: 255 }) + }), + unlimitedBatchSize: z.boolean(), + maxBatchSize: z + .number() + .int() + .min(1, { message: t("provisioningKeysMaxBatchSizeInvalid") }) + .max(1_000_000, { + message: t("provisioningKeysMaxBatchSizeInvalid") + }), + validUntil: z.string().optional(), + approveNewSites: z.boolean() + }) + .superRefine((data, ctx) => { + const v = data.validUntil; + if (v == null || v.trim() === "") { + return; + } + if (Number.isNaN(Date.parse(v))) { + ctx.addIssue({ + code: "custom", + message: t("provisioningKeysValidUntilInvalid"), + path: ["validUntil"] + }); + } + }); + + type CreateFormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(createFormSchema), + defaultValues: { + name: "", + unlimitedBatchSize: false, + maxBatchSize: 100, + validUntil: "", + approveNewSites: true + } + }); + + useEffect(() => { + if (!open) { + setCreated(null); + form.reset({ + name: "", + unlimitedBatchSize: false, + maxBatchSize: 100, + validUntil: "", + approveNewSites: true + }); + } + }, [open, form]); + + async function onSubmit(data: CreateFormValues) { + setLoading(true); + try { + const res = await api + .put>( + `/org/${orgId}/site-provisioning-key`, + { + name: data.name, + maxBatchSize: data.unlimitedBatchSize + ? null + : data.maxBatchSize, + validUntil: + data.validUntil == null || + data.validUntil.trim() === "" + ? undefined + : data.validUntil, + approveNewSites: data.approveNewSites + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("provisioningKeysErrorCreate"), + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + setCreated(res.data.data); + router.refresh(); + } + } finally { + setLoading(false); + } + } + + const credential = created && created.siteProvisioningKey; + + const unlimitedBatchSize = form.watch("unlimitedBatchSize"); + + return ( + + + + + {created + ? t("provisioningKeysList") + : t("provisioningKeysCreate")} + + {!created && ( + + {t("provisioningKeysCreateDescription")} + + )} + + + {!created && ( + + + ( + + {t("name")} + + + + + + )} + /> + ( + + + {t( + "provisioningKeysMaxBatchSize" + )} + + + { + const v = e.target.value; + field.onChange( + v === "" + ? 100 + : Number(v) + ); + }} + value={field.value} + /> + + + + )} + /> + ( + + + + field.onChange( + c === true + ) + } + /> + + + {t( + "provisioningKeysUnlimitedBatchSize" + )} + + + )} + /> + { + const dateTimeValue: DateTimeValue = + (() => { + if (!field.value) return {}; + const d = new Date(field.value); + if (isNaN(d.getTime())) + return {}; + const hours = d + .getHours() + .toString() + .padStart(2, "0"); + const minutes = d + .getMinutes() + .toString() + .padStart(2, "0"); + const seconds = d + .getSeconds() + .toString() + .padStart(2, "0"); + return { + date: d, + time: `${hours}:${minutes}:${seconds}` + }; + })(); + + return ( + + + {t( + "provisioningKeysValidUntil" + )} + + + { + if (!value.date) { + field.onChange( + "" + ); + return; + } + const d = new Date( + value.date + ); + if (value.time) { + const [h, m, s] = + value.time.split( + ":" + ); + d.setHours( + parseInt( + h, + 10 + ), + parseInt( + m, + 10 + ), + parseInt( + s || "0", + 10 + ) + ); + } + field.onChange( + d.toISOString() + ); + }} + /> + + + {t( + "provisioningKeysValidUntilHint" + )} + + + + ); + }} + /> + ( + + + + field.onChange( + c === true + ) + } + /> + +
+ + {t( + "provisioningKeysApproveNewSites" + )} + + + {t( + "provisioningKeysApproveNewSitesDescription" + )} + +
+
+ )} + /> + + + )} + + {created && credential && ( +
+ + + + {t("provisioningKeysSave")} + + + {t("provisioningKeysSaveDescription")} + + + +
+ )} +
+ + {!created ? ( + <> + + + + + + ) : ( + + + + )} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 866aebe3a..690ad405d 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -18,7 +18,7 @@ import { resourceQueries } from "@app/lib/queries"; import { ListSitesResponse } from "@server/routers/site"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useState, useTransition } from "react"; import { cleanForFQDN, InternalResourceForm, @@ -49,10 +49,9 @@ export default function EditInternalResourceDialog({ const t = useTranslations(); const api = createApiClient(useEnvContext()); const queryClient = useQueryClient(); - const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmitting, startTransition] = useTransition(); async function handleSubmit(values: InternalResourceFormValues) { - setIsSubmitting(true); try { let data = { ...values }; if (data.mode === "host" && isHostname(data.destination)) { @@ -70,6 +69,7 @@ export default function EditInternalResourceDialog({ name: data.name, siteId: data.siteId, mode: data.mode, + niceId: data.niceId, destination: data.destination, alias: data.alias && @@ -127,8 +127,6 @@ export default function EditInternalResourceDialog({ ), variant: "destructive" }); - } finally { - setIsSubmitting(false); } } @@ -162,7 +160,9 @@ export default function EditInternalResourceDialog({ orgId={orgId} siteResourceId={resource.id} formId="edit-internal-resource-form" - onSubmit={handleSubmit} + onSubmit={(values) => + startTransition(() => handleSubmit(values)) + } /> diff --git a/src/components/EditSiteProvisioningKeyCredenza.tsx b/src/components/EditSiteProvisioningKeyCredenza.tsx new file mode 100644 index 000000000..e0e9cdde0 --- /dev/null +++ b/src/components/EditSiteProvisioningKeyCredenza.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Button } from "@app/components/ui/button"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { Input } from "@app/components/ui/input"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; +import { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + DateTimePicker, + DateTimeValue +} from "@app/components/DateTimePicker"; + +const FORM_ID = "edit-site-provisioning-key-form"; + +export type EditableSiteProvisioningKey = { + id: string; + name: string; + maxBatchSize: number | null; + validUntil: string | null; + approveNewSites: boolean; +}; + +type EditSiteProvisioningKeyCredenzaProps = { + open: boolean; + setOpen: (open: boolean) => void; + orgId: string; + provisioningKey: EditableSiteProvisioningKey | null; +}; + +export default function EditSiteProvisioningKeyCredenza({ + open, + setOpen, + orgId, + provisioningKey +}: EditSiteProvisioningKeyCredenzaProps) { + const t = useTranslations(); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + + const editFormSchema = z + .object({ + name: z.string(), + unlimitedBatchSize: z.boolean(), + maxBatchSize: z + .number() + .int() + .min(1, { message: t("provisioningKeysMaxBatchSizeInvalid") }) + .max(1_000_000, { + message: t("provisioningKeysMaxBatchSizeInvalid") + }), + validUntil: z.string().optional(), + approveNewSites: z.boolean() + }) + .superRefine((data, ctx) => { + const v = data.validUntil; + if (v == null || v.trim() === "") { + return; + } + if (Number.isNaN(Date.parse(v))) { + ctx.addIssue({ + code: "custom", + message: t("provisioningKeysValidUntilInvalid"), + path: ["validUntil"] + }); + } + }); + + type EditFormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(editFormSchema), + defaultValues: { + name: "", + unlimitedBatchSize: false, + maxBatchSize: 100, + validUntil: "", + approveNewSites: true + } + }); + + useEffect(() => { + if (!open || !provisioningKey) { + return; + } + form.reset({ + name: provisioningKey.name, + unlimitedBatchSize: provisioningKey.maxBatchSize == null, + maxBatchSize: provisioningKey.maxBatchSize ?? 100, + validUntil: provisioningKey.validUntil ?? "", + approveNewSites: provisioningKey.approveNewSites + }); + }, [open, provisioningKey, form]); + + async function onSubmit(data: EditFormValues) { + if (!provisioningKey) { + return; + } + setLoading(true); + try { + const res = await api + .patch< + AxiosResponse + >( + `/org/${orgId}/site-provisioning-key/${provisioningKey.id}`, + { + maxBatchSize: data.unlimitedBatchSize + ? null + : data.maxBatchSize, + validUntil: + data.validUntil == null || + data.validUntil.trim() === "" + ? "" + : data.validUntil, + approveNewSites: data.approveNewSites + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("provisioningKeysUpdateError"), + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("provisioningKeysUpdated"), + description: t("provisioningKeysUpdatedDescription") + }); + setOpen(false); + router.refresh(); + } + } finally { + setLoading(false); + } + } + + const unlimitedBatchSize = form.watch("unlimitedBatchSize"); + + if (!provisioningKey) { + return null; + } + + return ( + + + + {t("provisioningKeysEdit")} + + {t("provisioningKeysEditDescription")} + + + +
+ + ( + + {t("name")} + + + + + )} + /> + ( + + + {t("provisioningKeysMaxBatchSize")} + + + { + const v = e.target.value; + field.onChange( + v === "" + ? 100 + : Number(v) + ); + }} + value={field.value} + /> + + + + )} + /> + ( + + + + field.onChange(c === true) + } + /> + + + {t( + "provisioningKeysUnlimitedBatchSize" + )} + + + )} + /> + ( + + + + field.onChange(c === true) + } + /> + +
+ + {t( + "provisioningKeysApproveNewSites" + )} + + + {t( + "provisioningKeysApproveNewSitesDescription" + )} + +
+
+ )} + /> + { + const dateTimeValue: DateTimeValue = + (() => { + if (!field.value) return {}; + const d = new Date(field.value); + if (isNaN(d.getTime())) return {}; + const hours = d + .getHours() + .toString() + .padStart(2, "0"); + const minutes = d + .getMinutes() + .toString() + .padStart(2, "0"); + const seconds = d + .getSeconds() + .toString() + .padStart(2, "0"); + return { + date: d, + time: `${hours}:${minutes}:${seconds}` + }; + })(); + + return ( + + + {t("provisioningKeysValidUntil")} + + + { + if (!value.date) { + field.onChange(""); + return; + } + const d = new Date( + value.date + ); + if (value.time) { + const [h, m, s] = + value.time.split( + ":" + ); + d.setHours( + parseInt(h, 10), + parseInt(m, 10), + parseInt( + s || "0", + 10 + ) + ); + } + field.onChange( + d.toISOString() + ); + }} + /> + + + {t("provisioningKeysValidUntilHint")} + + + + ); + }} + /> + + +
+ + + + + + +
+
+ ); +} diff --git a/src/components/HttpDestinationCredenza.tsx b/src/components/HttpDestinationCredenza.tsx new file mode 100644 index 000000000..e39567332 --- /dev/null +++ b/src/components/HttpDestinationCredenza.tsx @@ -0,0 +1,773 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { Label } from "@app/components/ui/label"; +import { Switch } from "@app/components/ui/switch"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { Textarea } from "@app/components/ui/textarea"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { Plus, X } from "lucide-react"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { build } from "@server/build"; +import { useTranslations } from "next-intl"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type AuthType = "none" | "bearer" | "basic" | "custom"; + +export type PayloadFormat = "json_array" | "ndjson" | "json_single"; + +export interface HttpConfig { + name: string; + url: string; + authType: AuthType; + bearerToken?: string; + basicCredentials?: string; + customHeaderName?: string; + customHeaderValue?: string; + headers: Array<{ key: string; value: string }>; + format: PayloadFormat; + useBodyTemplate: boolean; + bodyTemplate?: string; +} + +export interface Destination { + destinationId: number; + orgId: string; + type: string; + config: string; + enabled: boolean; + sendAccessLogs: boolean; + sendActionLogs: boolean; + sendConnectionLogs: boolean; + sendRequestLogs: boolean; + createdAt: number; + updatedAt: number; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +export const defaultHttpConfig = (): HttpConfig => ({ + name: "", + url: "", + authType: "none", + bearerToken: "", + basicCredentials: "", + customHeaderName: "", + customHeaderValue: "", + headers: [], + format: "json_array", + useBodyTemplate: false, + bodyTemplate: "" +}); + +export function parseHttpConfig(raw: string): HttpConfig { + try { + return { ...defaultHttpConfig(), ...JSON.parse(raw) }; + } catch { + return defaultHttpConfig(); + } +} + +// ── Headers editor ───────────────────────────────────────────────────────────── + +interface HeadersEditorProps { + headers: Array<{ key: string; value: string }>; + onChange: (headers: Array<{ key: string; value: string }>) => void; +} + +function HeadersEditor({ headers, onChange }: HeadersEditorProps) { + const t = useTranslations(); + + const addRow = () => onChange([...headers, { key: "", value: "" }]); + + const removeRow = (i: number) => + onChange(headers.filter((_, idx) => idx !== i)); + + const updateRow = (i: number, field: "key" | "value", val: string) => { + const next = [...headers]; + next[i] = { ...next[i], [field]: val }; + onChange(next); + }; + + return ( +
+ {headers.length === 0 && ( +

+ {t("httpDestNoHeadersConfigured")} +

+ )} + {headers.map((h, i) => ( +
+ updateRow(i, "key", e.target.value)} + placeholder={t("httpDestHeaderNamePlaceholder")} + className="flex-1" + /> + + updateRow(i, "value", e.target.value) + } + placeholder={t("httpDestHeaderValuePlaceholder")} + className="flex-1" + /> + +
+ ))} + +
+ ); +} + +// ── Component ────────────────────────────────────────────────────────────────── + +export interface HttpDestinationCredenzaProps { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: Destination | null; + orgId: string; + onSaved: () => void; +} + +export function HttpDestinationCredenza({ + open, + onOpenChange, + editing, + orgId, + onSaved +}: HttpDestinationCredenzaProps) { + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + + const [saving, setSaving] = useState(false); + const [cfg, setCfg] = useState(defaultHttpConfig()); + const [sendAccessLogs, setSendAccessLogs] = useState(false); + const [sendActionLogs, setSendActionLogs] = useState(false); + const [sendConnectionLogs, setSendConnectionLogs] = useState(false); + const [sendRequestLogs, setSendRequestLogs] = useState(false); + + useEffect(() => { + if (open) { + setCfg( + editing ? parseHttpConfig(editing.config) : defaultHttpConfig() + ); + setSendAccessLogs(editing?.sendAccessLogs ?? false); + setSendActionLogs(editing?.sendActionLogs ?? false); + setSendConnectionLogs(editing?.sendConnectionLogs ?? false); + setSendRequestLogs(editing?.sendRequestLogs ?? false); + } + }, [open, editing]); + + const update = (patch: Partial) => + setCfg((prev) => ({ ...prev, ...patch })); + + const urlError: string | null = (() => { + const raw = cfg.url.trim(); + if (!raw) return null; + try { + const parsed = new URL(raw); + if ( + parsed.protocol !== "http:" && + parsed.protocol !== "https:" + ) { + return t("httpDestUrlErrorHttpRequired"); + } + if (build === "saas" && parsed.protocol !== "https:") { + return t("httpDestUrlErrorHttpsRequired"); + } + return null; + } catch { + return t("httpDestUrlErrorInvalid"); + } + })(); + + const isValid = + cfg.name.trim() !== "" && + cfg.url.trim() !== "" && + urlError === null; + + async function handleSave() { + if (!isValid) return; + setSaving(true); + try { + const payload = { + type: "http", + config: JSON.stringify(cfg), + sendAccessLogs, + sendActionLogs, + sendConnectionLogs, + sendRequestLogs + }; + if (editing) { + await api.post( + `/org/${orgId}/event-streaming-destination/${editing.destinationId}`, + payload + ); + toast({ title: t("httpDestUpdatedSuccess") }); + } else { + await api.put( + `/org/${orgId}/event-streaming-destination`, + payload + ); + toast({ title: t("httpDestCreatedSuccess") }); + } + onSaved(); + onOpenChange(false); + } catch (e) { + toast({ + variant: "destructive", + title: editing + ? t("httpDestUpdateFailed") + : t("httpDestCreateFailed"), + description: formatAxiosError( + e, + t("streamingUnexpectedError") + ) + }); + } finally { + setSaving(false); + } + } + + return ( + + + + + {editing + ? t("httpDestEditTitle") + : t("httpDestAddTitle")} + + + {editing + ? t("httpDestEditDescription") + : t("httpDestAddDescription")} + + + + + + {/* ── Settings tab ────────────────────────────── */} +
+ {/* Name */} +
+ + + update({ name: e.target.value }) + } + /> +
+ + {/* URL */} +
+ + + update({ url: e.target.value }) + } + /> + {urlError && ( +

+ {urlError} +

+ )} +
+ + {/* Authentication */} +
+
+ +

+ {t("httpDestAuthDescription")} +

+
+ + + update({ authType: v as AuthType }) + } + className="gap-2" + > + {/* None */} +
+ +
+ +

+ {t("httpDestAuthNoneDescription")} +

+
+
+ + {/* Bearer */} +
+ +
+
+ +

+ {t("httpDestAuthBearerDescription")} +

+
+ {cfg.authType === "bearer" && ( + + update({ + bearerToken: + e.target.value + }) + } + /> + )} +
+
+ + {/* Basic */} +
+ +
+
+ +

+ {t("httpDestAuthBasicDescription")} +

+
+ {cfg.authType === "basic" && ( + + update({ + basicCredentials: + e.target.value + }) + } + /> + )} +
+
+ + {/* Custom */} +
+ +
+
+ +

+ {t("httpDestAuthCustomDescription")} +

+
+ {cfg.authType === "custom" && ( +
+ + update({ + customHeaderName: + e.target + .value + }) + } + className="flex-1" + /> + + update({ + customHeaderValue: + e.target + .value + }) + } + className="flex-1" + /> +
+ )} +
+
+
+
+
+ + {/* ── Headers tab ──────────────────────────────── */} +
+
+ +

+ {t("httpDestCustomHeadersDescription")} +

+
+ update({ headers })} + /> +
+ + {/* ── Body tab ─────────────────────────── */} +
+
+ +

+ {t("httpDestBodyTemplateDescription")} +

+
+ +
+ + update({ useBodyTemplate: v }) + } + /> + +
+ + {cfg.useBodyTemplate && ( +
+ +