mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-15 04:57:24 +00:00
Compare commits
182 Commits
dependabot
...
resource-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9df995e76 | ||
|
|
efb1d69ac9 | ||
|
|
107986d848 | ||
|
|
b6c8fbe43b | ||
|
|
4208a9f372 | ||
|
|
3c82a228fb | ||
|
|
a4aa29e48a | ||
|
|
0f82ba6627 | ||
|
|
1df5d9fac8 | ||
|
|
5189583d73 | ||
|
|
b794d2aa40 | ||
|
|
c69059b227 | ||
|
|
b27b62d4c8 | ||
|
|
ee8290d68c | ||
|
|
82e8e79b16 | ||
|
|
2d428d2fa0 | ||
|
|
0005c11a0a | ||
|
|
f91d914ec6 | ||
|
|
b6caeda0a5 | ||
|
|
77d17af15b | ||
|
|
264c6bf4e8 | ||
|
|
4aa72eb1a3 | ||
|
|
a066a68e1a | ||
|
|
9fb677e952 | ||
|
|
88d8414eb8 | ||
|
|
5f3fafb1b0 | ||
|
|
de1338a8cd | ||
|
|
0800aa2a61 | ||
|
|
4959d66ac1 | ||
|
|
9320df8be6 | ||
|
|
13ec6b6620 | ||
|
|
2ca3ef019c | ||
|
|
724e41a54f | ||
|
|
ce5e62d216 | ||
|
|
874dc2b33e | ||
|
|
3b2622d590 | ||
|
|
c81d855741 | ||
|
|
3bce8d3596 | ||
|
|
ee2a1e2bc3 | ||
|
|
a0f3ee74f9 | ||
|
|
82a36fd632 | ||
|
|
c5084137ab | ||
|
|
65ec8da100 | ||
|
|
e76e7581a5 | ||
|
|
a97a4b6ec1 | ||
|
|
e38bbde348 | ||
|
|
026260ddfb | ||
|
|
97be5eb7d5 | ||
|
|
d7b96ba3f5 | ||
|
|
b42672530f | ||
|
|
b6b2dbd8ab | ||
|
|
975f3a01f5 | ||
|
|
4de2dfff85 | ||
|
|
27d230647f | ||
|
|
114486608e | ||
|
|
10fa9274d0 | ||
|
|
cbdc74768f | ||
|
|
10f95896aa | ||
|
|
5b8994d143 | ||
|
|
c46ef2fe9c | ||
|
|
4cd025dd91 | ||
|
|
ce04ea9720 | ||
|
|
a3ce382725 | ||
|
|
4eb49e3e60 | ||
|
|
2a9481023a | ||
|
|
8ed01372b8 | ||
|
|
6a7d4fd385 | ||
|
|
7bc08c0425 | ||
|
|
451f3d24a8 | ||
|
|
c4b3656fad | ||
|
|
54c1dd3bae | ||
|
|
a8f4d2b7d1 | ||
|
|
51f1693dbd | ||
|
|
b33a6e6fac | ||
|
|
fc2c13a686 | ||
|
|
f4602a120e | ||
|
|
7ccceeea0d | ||
|
|
f81f78f294 | ||
|
|
6cab223f12 | ||
|
|
7b05c02508 | ||
|
|
5922bfb1a0 | ||
|
|
43f2e32231 | ||
|
|
20ebdc6289 | ||
|
|
a80ae49a33 | ||
|
|
660197eef1 | ||
|
|
f3eb823bc3 | ||
|
|
61c13db090 | ||
|
|
ccbd793f52 | ||
|
|
d13e6896a8 | ||
|
|
83a36ead10 | ||
|
|
b61b74b0b5 | ||
|
|
01b068c50f | ||
|
|
fee44ce960 | ||
|
|
1906504a86 | ||
|
|
36bcba332c | ||
|
|
304ab1964c | ||
|
|
b286096c7b | ||
|
|
a22a4b6e74 | ||
|
|
9a680d2374 | ||
|
|
f80e212b07 | ||
|
|
8a39b3fd45 | ||
|
|
61ec938b00 | ||
|
|
6686de6788 | ||
|
|
79636cbb30 | ||
|
|
2fa1bc6cdc | ||
|
|
c5f6d822ca | ||
|
|
4de4bf9625 | ||
|
|
5d956080f2 | ||
|
|
f8e18de2fc | ||
|
|
884482ec35 | ||
|
|
9b43948fa4 | ||
|
|
bcd6cd99cc | ||
|
|
37ceba6b81 | ||
|
|
dfe42e9016 | ||
|
|
38aa2dace8 | ||
|
|
136c3eff0c | ||
|
|
642999c8b1 | ||
|
|
c5fc49b4fa | ||
|
|
cd5a38b1eb | ||
|
|
595842c2c9 | ||
|
|
82d5276ade | ||
|
|
51eb782831 | ||
|
|
de2980e1bc | ||
|
|
8a3c0d9a08 | ||
|
|
1a5e9f1005 | ||
|
|
f42c013f33 | ||
|
|
42c9bda939 | ||
|
|
cbce9fae3a | ||
|
|
e44b15ecd5 | ||
|
|
7f6ca31757 | ||
|
|
a1eb248474 | ||
|
|
be2b1fd1ce | ||
|
|
20b65f549e | ||
|
|
1dc8be373c | ||
|
|
22b2e6b3d4 | ||
|
|
89e7107a47 | ||
|
|
0a69131c38 | ||
|
|
590f2c29b3 | ||
|
|
0ddcce6fe1 | ||
|
|
8a54fb7f23 | ||
|
|
5c280b024e | ||
|
|
033cc62ce7 | ||
|
|
4c69b7a64e | ||
|
|
e7ab9b3f37 | ||
|
|
3143662f82 | ||
|
|
18964ba2a3 | ||
|
|
f862404c5c | ||
|
|
c292578f80 | ||
|
|
7b02d4104d | ||
|
|
2ef5d90e13 | ||
|
|
d6a8021613 | ||
|
|
c5231d37f6 | ||
|
|
4d803a40c9 | ||
|
|
1d709b551a | ||
|
|
335411de4c | ||
|
|
0e4abdf4b6 | ||
|
|
267b40b73c | ||
|
|
ba9a0c5e3c | ||
|
|
9e0b7ff0d7 | ||
|
|
003bf7fdf3 | ||
|
|
c3fdda026b | ||
|
|
a53363d064 | ||
|
|
ee21e1faa7 | ||
|
|
e409a34a09 | ||
|
|
7177ab7f77 | ||
|
|
801f6fb661 | ||
|
|
805d82b8d9 | ||
|
|
bd6d790495 | ||
|
|
2305163474 | ||
|
|
dda53dcb16 | ||
|
|
2c3e768867 | ||
|
|
8d682ed9ad | ||
|
|
47fe497ca1 | ||
|
|
4d5f364663 | ||
|
|
c3db8b972f | ||
|
|
cfced63ba1 | ||
|
|
51aa55f963 | ||
|
|
e7df24841e | ||
|
|
e6fd4c32c4 | ||
|
|
f6590aedbd | ||
|
|
3cb9e02533 | ||
|
|
4d792350ef |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -17,9 +17,9 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite*
|
||||
!Dockerfile.sqlite
|
||||
*.sqlite3
|
||||
*.sqlite3*
|
||||
*.log
|
||||
.machinelogs*.json
|
||||
*-audit.json
|
||||
@@ -54,3 +54,4 @@ hydrateSaas.ts
|
||||
CLAUDE.md
|
||||
drizzle.config.ts
|
||||
server/setup/migrations.ts
|
||||
solo.yml
|
||||
60
cli/commands/disableUser2fa.ts
Normal file
60
cli/commands/disableUser2fa.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { CommandModule } from "yargs";
|
||||
import { db, users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Disable 2FA for a user by email address.
|
||||
*/
|
||||
type DisableUser2faArgs = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const disableUser2fa: CommandModule<{}, DisableUser2faArgs> = {
|
||||
command: "disable-user-2fa",
|
||||
describe: "Disable 2FA for a user (sets twoFactorEnabled=false, clears secret)",
|
||||
builder: (yargs) => {
|
||||
return yargs.option("email", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "User email address"
|
||||
});
|
||||
},
|
||||
handler: async (argv: { email: string }) => {
|
||||
try {
|
||||
const { email } = argv;
|
||||
console.log(`Looking for user with email: ${email}`);
|
||||
|
||||
// Find the user by email
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
console.error(`User with email '${email}' not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!user.twoFactorEnabled) {
|
||||
console.log(`2FA is already disabled for user '${email}'.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Update user: disable 2FA and clear secret
|
||||
await db.update(users)
|
||||
.set({
|
||||
twoFactorEnabled: false,
|
||||
twoFactorSecret: null,
|
||||
twoFactorSetupRequested: false
|
||||
})
|
||||
.where(eq(users.userId, user.userId));
|
||||
|
||||
console.log(`2FA disabled for user '${email}'.`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Error disabling 2FA:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { clearLicenseKeys } from "./commands/clearLicenseKeys";
|
||||
import { deleteClient } from "./commands/deleteClient";
|
||||
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
|
||||
import { clearCertificates } from "./commands/clearCertificates";
|
||||
import { disableUser2fa } from "./commands/disableUser2fa";
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.scriptName("pangctl")
|
||||
@@ -21,5 +22,6 @@ yargs(hideBin(process.argv))
|
||||
.command(deleteClient)
|
||||
.command(generateOrgCaKeys)
|
||||
.command(clearCertificates)
|
||||
.command(disableUser2fa)
|
||||
.demandCommand()
|
||||
.help().argv;
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Възникна грешка при изтриване на връзката",
|
||||
"shareDeleted": "Връзката беше изтрита",
|
||||
"shareDeletedDescription": "Връзката беше премахната",
|
||||
"shareDelete": "Изтрийте споделената връзка",
|
||||
"shareDeleteConfirm": "Потвърдете изтриването на споделената връзка",
|
||||
"shareQuestionRemove": "Сигурни ли сте, че искате да изтриете тази споделена връзка?",
|
||||
"shareMessageRemove": "След изтриване връзката вече няма да работи и всеки, който я използва, ще загуби достъп до ресурса.",
|
||||
"shareTokenDescription": "Достъпният токен може да бъде предаван по два начина: като параметър или в хедърите на заявките. Те трябва да бъдат предавани от клиента при всяка заявка за удостоверен достъп.",
|
||||
"accessToken": "Достъп Токен",
|
||||
"usageExamples": "Примери за използване",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "След като бъде премахнат, този потребител няма да има достъп до организацията. Винаги можете да го поканите отново по-късно, но той ще трябва да приеме отново поканата.",
|
||||
"userRemoveOrgConfirm": "Потвърдете премахването на потребителя",
|
||||
"userRemoveOrg": "Премахване на потребителя от организацията",
|
||||
"userQuestionOrgRemoveSelf": "Сигурни ли сте, че искате да премахнете себе си от тази организация?",
|
||||
"userMessageOrgRemoveSelf": "Ще загубите достъп незабавно. Администратор може да ви покани отново по-късно, но ще трябва да приемете нова покана.",
|
||||
"userRemoveOrgConfirmSelf": "Потвърдете премахването на себе си",
|
||||
"userRemoveOrgSelf": "Премахнете себе си от организацията",
|
||||
"userRemoveOrgSelfWarning": "Ще загубите достъп до тази организация незабавно.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "ПРЕМАХНЕТЕ МЕ ОТ ОРГАНИЗАЦИЯТА",
|
||||
"users": "Потребители",
|
||||
"accessRoleMember": "Член",
|
||||
"accessRoleOwner": "Собственик",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Невалиден имейл адрес",
|
||||
"inviteValidityDuration": "Моля, изберете продължителност",
|
||||
"accessRoleSelectPlease": "Моля, изберете роля",
|
||||
"removeOwnAdminRoleConfirmTitle": "Премахване на административния ви достъп?",
|
||||
"removeOwnAdminRoleConfirmDescription": "След записване няма да имате повече администраторски права в тази организация. Друг администратор може да възстанови достъпа, ако е необходимо.",
|
||||
"removeOwnAdminRoleConfirmButton": "Премахнете административния ми достъп",
|
||||
"removeOwnAdminRoleConfirmPhrase": "ПРЕМАХНЕТЕ АДМИНИСТРАТИВНИЯ МИ ДОСТЪП",
|
||||
"ownerMustRetainAdminRole": "Собственикът на организацията трябва да запази поне една администраторска роля.",
|
||||
"usernameRequired": "Необходимо е потребителско име",
|
||||
"idpSelectPlease": "Моля, изберете доставчик на идентичност",
|
||||
"idpGenericOidc": "Основен OAuth2/OIDC доставчик.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "Добавянето на повече от една цел ще активира натоварването на баланса.",
|
||||
"targetsSubmit": "Запазване на целите",
|
||||
"addTarget": "Добавете цел",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Роунд Робин маршрутизирането няма да работи между сайтове, които не са свързани към един и същ възел, но автоматичното превключване ще работи.",
|
||||
"targetErrorInvalidIp": "Невалиден IP адрес",
|
||||
"targetErrorInvalidIpDescription": "Моля, въведете валиден IP адрес или име на хост",
|
||||
"targetErrorInvalidPort": "Невалиден порт",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Валидна парола",
|
||||
"validEmail": "Валиден имейл",
|
||||
"validSSO": "Валидно SSO",
|
||||
"view": "Преглед",
|
||||
"configManaged": "Управлявана конфигурация",
|
||||
"connectedClient": "Свързан клиент",
|
||||
"resourceBlocked": "Блокирани ресурси",
|
||||
"droppedByRule": "Прекратено от правило",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"streamingDatadogTitle": "Datadog",
|
||||
"streamingDatadogDescription": "Пресочвайте събития директно към вашият акаунт в Datadog. Очаквайте скоро.",
|
||||
"streamingTypePickerDescription": "Изберете вид на дестинацията, за да започнете.",
|
||||
"streamingFailedToLoad": "Неуспешно зареждане на дестинации",
|
||||
"streamingLastSyncError": "Възникна грешка при последната синхронизация",
|
||||
"streamingUnexpectedError": "Възникна неочаквана грешка.",
|
||||
"streamingFailedToUpdate": "Неуспешно актуализиране на дестинация",
|
||||
"streamingDeletedSuccess": "Дестинацията беше изтрита успешно",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Редактиране на дестинацията",
|
||||
"S3DestAddTitle": "Добавете S3 дестинация",
|
||||
"S3DestEditDescription": "Актуализирайте конфигурацията за тази S3 дестинация за предаване на събития.",
|
||||
"S3DestAddDescription": "Конфигурирайте нов крайна точка на S3, за да получавате събития на вашата организация.",
|
||||
"S3DestAddDescription": "Конфигурирайте ново хранилище Amazon S3 (или съвместимо с S3), за да получавате събития на вашата организация.",
|
||||
"s3DestTabSettings": "Настройки",
|
||||
"s3DestTabFormat": "Формат",
|
||||
"s3DestNameLabel": "Име",
|
||||
"s3DestNamePlaceholder": "Моята S3 дестинация",
|
||||
"s3DestAccessKeyIdLabel": "Идентификатор на достъп за AWS Key ID",
|
||||
"s3DestSecretAccessKeyLabel": "Тайният ключ за достъп на AWS",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Вашият таен ключ за достъп за AWS",
|
||||
"s3DestRegionLabel": "AWS Регион",
|
||||
"s3DestBucketLabel": "Име на хранилище",
|
||||
"s3DestPrefixLabel": "Префикс на ключ (по избор)",
|
||||
"s3DestPrefixDescription": "По избор пътеводен префикс, добавен към всеки обектен ключ. Обектите се съхраняват в {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Потребителски крайна точка (по избор)",
|
||||
"s3DestEndpointDescription": "Заместете крайната точка на S3 за съвместимо с S3 хранилище като MinIO или Cloudflare R2. Оставете празно за стандартното AWS S3.",
|
||||
"s3DestGzipLabel": "Gzip компресия",
|
||||
"s3DestGzipDescription": "Компресирайте всеки качен обект с gzip. Намалява разходите за съхранение и размера на качването.",
|
||||
"s3DestFormatTitle": "Формат на файл",
|
||||
"s3DestFormatDescription": "Как събитията са сериализирани вътре във всеки качен обект.",
|
||||
"s3DestFormatJsonArrayDescription": "Всеки обект е JSON масив от записи на събития. Съвместим с повечето аналитични инструменти.",
|
||||
"s3DestFormatNdjsonDescription": "Всеки обект съдържа един JSON запис на ред (форматиран JSON с нов ред). Съвместим с Athena, BigQuery и Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Всеки обект е RFC-4180 CSV файл с ред заглавие. Имената на колоните са извлечени от полетата на данните за събитията.",
|
||||
"s3DestSaveChanges": "Запази промените",
|
||||
"s3DestCreateDestination": "Създаване на дестинация",
|
||||
"s3DestUpdatedSuccess": "Дестинацията е актуализирана успешно",
|
||||
"s3DestCreatedSuccess": "Дестинацията е създадена успешно",
|
||||
"s3DestUpdateFailed": "Неуспешно актуализиране на дестинацията",
|
||||
"s3DestCreateFailed": "Неуспешно създаване на дестинация",
|
||||
"datadogDestEditTitle": "Редактиране на дестинация",
|
||||
"datadogDestAddTitle": "Добавяне на Datadog дестинация",
|
||||
"datadogDestEditDescription": "Актуализирайте конфигурацията за тази Datadog дестинация за предаване на събития.",
|
||||
@@ -3174,7 +3219,7 @@
|
||||
"publicIpEndpoint": "Крайна точка",
|
||||
"lastTriggeredAt": "Последен тригер",
|
||||
"reject": "Отхвърляне",
|
||||
"uptimeDaysAgo": "{count} days ago",
|
||||
"uptimeDaysAgo": "преди {count} дни",
|
||||
"uptimeToday": "Днес",
|
||||
"uptimeNoDataAvailable": "Няма налични данни",
|
||||
"uptimeSuffix": "време без прекъсване",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Došlo k chybě při odstraňování odkazu",
|
||||
"shareDeleted": "Odkaz odstraněn",
|
||||
"shareDeletedDescription": "Odkaz byl odstraněn",
|
||||
"shareDelete": "Smazat odkaz ke sdílení",
|
||||
"shareDeleteConfirm": "Potvrdit smazání odkazu ke sdílení",
|
||||
"shareQuestionRemove": "Jste si jisti, že chcete smazat tento odkaz ke sdílení?",
|
||||
"shareMessageRemove": "Jakmile bude smazán, odkaz přestane fungovat a všichni, kdo jej používají, ztratí přístup k prostředku.",
|
||||
"shareTokenDescription": "Přístupový token může být předán dvěma způsoby: jako parametr dotazu nebo v záhlaví požadavku. Tyto údaje musí být předány klientovi na každé žádosti o ověřený přístup.",
|
||||
"accessToken": "Přístupový token",
|
||||
"usageExamples": "Příklady použití",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "Po odstranění tohoto uživatele již nebude mít přístup k organizaci. Vždy je můžete znovu pozvat později, ale budou muset pozvání znovu přijmout.",
|
||||
"userRemoveOrgConfirm": "Potvrdit odebrání uživatele",
|
||||
"userRemoveOrg": "Odebrat uživatele z organizace",
|
||||
"userQuestionOrgRemoveSelf": "Jste si jisti, že se chcete odstranit z této organizace?",
|
||||
"userMessageOrgRemoveSelf": "Okamžitě ztratíte přístup. Administrátor vás může později znovu pozvat, ale budete muset přijmout nové pozvání.",
|
||||
"userRemoveOrgConfirmSelf": "Potvrdit odstranění sebe",
|
||||
"userRemoveOrgSelf": "Odstranit se z organizace",
|
||||
"userRemoveOrgSelfWarning": "Okamžitě ztratíte přístup k této organizaci.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "ODSTRANIT SE Z ORGANIZACE",
|
||||
"users": "Uživatelé",
|
||||
"accessRoleMember": "Člen",
|
||||
"accessRoleOwner": "Vlastník",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Neplatná e-mailová adresa",
|
||||
"inviteValidityDuration": "Zvolte prosím dobu trvání",
|
||||
"accessRoleSelectPlease": "Vyberte prosím roli",
|
||||
"removeOwnAdminRoleConfirmTitle": "Odebrat přístup správce?",
|
||||
"removeOwnAdminRoleConfirmDescription": "Po uložení již nebudete mít oprávnění správce v této organizaci. Další administrátor vám může přístup obnovit, pokud bude potřeba.",
|
||||
"removeOwnAdminRoleConfirmButton": "Odebrat mé administrátorské oprávnění",
|
||||
"removeOwnAdminRoleConfirmPhrase": "ODEBRAT MÉ ADMINISTRÁTORSKÉ OPRÁVNĚNÍ",
|
||||
"ownerMustRetainAdminRole": "Vlastník organizace musí zachovat alespoň jednu roli správce.",
|
||||
"usernameRequired": "Uživatelské jméno je povinné",
|
||||
"idpSelectPlease": "Vyberte poskytovatele identity",
|
||||
"idpGenericOidc": "Generic OAuth2/OIDC provider.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "Přidáním více než jednoho cíle se umožní vyvážení zatížení.",
|
||||
"targetsSubmit": "Uložit cíle",
|
||||
"addTarget": "Add Target",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Round robin routing nebude fungovat mezi lokalitami, které nejsou připojeny ke stejnému uzlu, ale failover bude fungovat.",
|
||||
"targetErrorInvalidIp": "Neplatná IP adresa",
|
||||
"targetErrorInvalidIpDescription": "Zadejte prosím platnou IP adresu nebo název hostitele",
|
||||
"targetErrorInvalidPort": "Neplatný port",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Platné heslo",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "Zobrazit",
|
||||
"configManaged": "Správa konfigurace",
|
||||
"connectedClient": "Připojený klient",
|
||||
"resourceBlocked": "Zablokované zdroje",
|
||||
"droppedByRule": "Zrušeno pravidlem",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"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",
|
||||
"streamingLastSyncError": "Došlo k chybě při poslední synchronizaci",
|
||||
"streamingUnexpectedError": "Došlo k neočekávané chybě.",
|
||||
"streamingFailedToUpdate": "Nepodařilo se aktualizovat cíl",
|
||||
"streamingDeletedSuccess": "Cíl byl úspěšně odstraněn",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Upravit cíl",
|
||||
"S3DestAddTitle": "Přidat S3 cíl",
|
||||
"S3DestEditDescription": "Aktualizujte konfiguraci tohoto S3 cíle pro streamování událostí.",
|
||||
"S3DestAddDescription": "Konfigurujte nový S3 koncový bod pro přijímání událostí vaší organizace.",
|
||||
"S3DestAddDescription": "Nakonfigurujte nový Amazon S3 (nebo S3-kompatibilní) bucket, aby přijímal události vaší organizace.",
|
||||
"s3DestTabSettings": "Nastavení",
|
||||
"s3DestTabFormat": "Formát",
|
||||
"s3DestNameLabel": "Jméno",
|
||||
"s3DestNamePlaceholder": "Moje cílové S3",
|
||||
"s3DestAccessKeyIdLabel": "ID přístupového klíče AWS",
|
||||
"s3DestSecretAccessKeyLabel": "Tajný přístupový klíč AWS",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Váš tajný přístupový klíč AWS",
|
||||
"s3DestRegionLabel": "Oblast AWS",
|
||||
"s3DestBucketLabel": "Název bucketu",
|
||||
"s3DestPrefixLabel": "Předpona klíče (volitelné)",
|
||||
"s3DestPrefixDescription": "Volitelná cesta předpony přidaná ke každému objektovému klíči. Objekty jsou uloženy na {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Vlastní koncový bod (volitelné)",
|
||||
"s3DestEndpointDescription": "Přepište koncový bod S3 pro S3-kompatibilní úložiště, jako je MinIO nebo Cloudflare R2. Nechte prázdné pro standardní AWS S3.",
|
||||
"s3DestGzipLabel": "Komprese Gzip",
|
||||
"s3DestGzipDescription": "Komprimujte každý nahraný objekt pomocí gzip. Snižuje náklady na uložení a velikost nahrávání.",
|
||||
"s3DestFormatTitle": "Formát souboru",
|
||||
"s3DestFormatDescription": "Jak jsou události serializovány v každém nahraném objektu.",
|
||||
"s3DestFormatJsonArrayDescription": "Každý objekt je pole JSON záznamů událostí. Kompatibilní s většinou analytických nástrojů.",
|
||||
"s3DestFormatNdjsonDescription": "Každý objekt obsahuje jeden JSON záznam na řádku (newline-delimited JSON). Kompatibilní s Athena, BigQuery a Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Každý objekt je soubor CSV podle RFC-4180 s řádkem záhlaví. Názvy sloupců jsou odvozeny z polí dat událostí.",
|
||||
"s3DestSaveChanges": "Uložit změny",
|
||||
"s3DestCreateDestination": "Vytvořit destinaci",
|
||||
"s3DestUpdatedSuccess": "Destinace úspěšně aktualizována",
|
||||
"s3DestCreatedSuccess": "Destinace úspěšně vytvořena",
|
||||
"s3DestUpdateFailed": "Aktualizace destinace se nezdařila",
|
||||
"s3DestCreateFailed": "Vytvoření destinace se nezdařilo",
|
||||
"datadogDestEditTitle": "Upravit cíl",
|
||||
"datadogDestAddTitle": "Přidat Datadog cíl",
|
||||
"datadogDestEditDescription": "Aktualizujte konfiguraci tohoto Datadog cíle pro streamování událostí.",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Fehler beim Löschen des Links",
|
||||
"shareDeleted": "Link gelöscht",
|
||||
"shareDeletedDescription": "Der Link wurde gelöscht",
|
||||
"shareDelete": "Freigabelink löschen",
|
||||
"shareDeleteConfirm": "Löschen des Freigabelinks bestätigen",
|
||||
"shareQuestionRemove": "Sind Sie sicher, dass Sie diesen Freigabelink löschen möchten?",
|
||||
"shareMessageRemove": "Nach dem Löschen funktioniert der Link nicht mehr, und jeder, der ihn nutzt, verliert den Zugriff auf die Ressource.",
|
||||
"shareTokenDescription": "Das Zugriffstoken kann auf zwei Arten übergeben werden: als Abfrageparameter oder in den Request-Headern. Diese müssen vom Client auf jeder Anfrage für authentifizierten Zugriff weitergegeben werden.",
|
||||
"accessToken": "Zugangs-Token",
|
||||
"usageExamples": "Nutzungsbeispiele",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "Nach dem Entfernen hat dieser Benutzer keinen Zugriff mehr auf die Organisation. Sie können ihn später jederzeit wieder einladen, aber er muss die Einladung erneut annehmen.",
|
||||
"userRemoveOrgConfirm": "Entfernen des Benutzers bestätigen",
|
||||
"userRemoveOrg": "Benutzer aus der Organisation entfernen",
|
||||
"userQuestionOrgRemoveSelf": "Sind Sie sicher, dass Sie sich aus dieser Organisation entfernen möchten?",
|
||||
"userMessageOrgRemoveSelf": "Sie verlieren sofort den Zugriff. Ein Administrator kann Sie später erneut einladen, aber Sie müssen eine neue Einladung annehmen.",
|
||||
"userRemoveOrgConfirmSelf": "Entfernung bestätigen",
|
||||
"userRemoveOrgSelf": "Sich selbst aus der Organisation entfernen",
|
||||
"userRemoveOrgSelfWarning": "Sie verlieren sofort den Zugriff auf diese Organisation.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "ENTFERNUNG MICH SELBST AUS DER ORGANISATION",
|
||||
"users": "Benutzer",
|
||||
"accessRoleMember": "Mitglied",
|
||||
"accessRoleOwner": "Eigentümer",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Ungültige E-Mail-Adresse",
|
||||
"inviteValidityDuration": "Bitte wählen Sie eine Dauer",
|
||||
"accessRoleSelectPlease": "Bitte wählen Sie eine Rolle",
|
||||
"removeOwnAdminRoleConfirmTitle": "Möchten Sie Ihren Administratorzugriff entfernen?",
|
||||
"removeOwnAdminRoleConfirmDescription": "Nach dem Speichern haben Sie keine Administratorrechte mehr in dieser Organisation. Ein anderer Administrator kann den Zugriff bei Bedarf wiederherstellen.",
|
||||
"removeOwnAdminRoleConfirmButton": "Meinen Administratorzugriff entfernen",
|
||||
"removeOwnAdminRoleConfirmPhrase": "NIMM MEINEN ADMIN-ZUGRIFF WEG",
|
||||
"ownerMustRetainAdminRole": "Der Organisationsinhaber muss mindestens eine Administratorrolle behalten.",
|
||||
"usernameRequired": "Benutzername ist erforderlich",
|
||||
"idpSelectPlease": "Bitte wählen Sie einen Identitätsanbieter",
|
||||
"idpGenericOidc": "Generischer OAuth2/OIDC-Anbieter.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "Das Hinzufügen von mehr als einem Ziel aktiviert den Lastausgleich.",
|
||||
"targetsSubmit": "Ziele speichern",
|
||||
"addTarget": "Ziel hinzufügen",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Round-Robin-Routing funktioniert nicht zwischen Standorten, die nicht mit demselben Knoten verbunden sind, aber Failover funktioniert.",
|
||||
"targetErrorInvalidIp": "Ungültige IP-Adresse",
|
||||
"targetErrorInvalidIpDescription": "Bitte geben Sie eine gültige IP-Adresse oder einen Hostnamen ein",
|
||||
"targetErrorInvalidPort": "Ungültiger Port",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Gültiges Passwort",
|
||||
"validEmail": "Gültige E-Mail-Adresse",
|
||||
"validSSO": "Gültige SSO-Anmeldung",
|
||||
"view": "Ansehen",
|
||||
"configManaged": "Konfiguration verwaltet",
|
||||
"connectedClient": "Verbundenes Gerät",
|
||||
"resourceBlocked": "Ressource blockiert",
|
||||
"droppedByRule": "Abgelegt durch Regel",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"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",
|
||||
"streamingLastSyncError": "Beim letzten Synchronisieren ist ein Fehler aufgetreten.",
|
||||
"streamingUnexpectedError": "Ein unerwarteter Fehler ist aufgetreten.",
|
||||
"streamingFailedToUpdate": "Fehler beim Aktualisieren des Ziels",
|
||||
"streamingDeletedSuccess": "Ziel erfolgreich gelöscht",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Ziel bearbeiten",
|
||||
"S3DestAddTitle": "S3-Ziel hinzufügen",
|
||||
"S3DestEditDescription": "Konfiguration für dieses S3-Ereignis-Streamingziel aktualisieren.",
|
||||
"S3DestAddDescription": "Neuen S3-Endpunkt konfigurieren, um die Ereignisse Ihrer Organisation zu erhalten.",
|
||||
"S3DestAddDescription": "Konfigurieren Sie einen neuen Amazon S3 (oder S3-kompatiblen) Bucket, um die Ereignisse Ihrer Organisation zu empfangen.",
|
||||
"s3DestTabSettings": "Einstellungen",
|
||||
"s3DestTabFormat": "Format",
|
||||
"s3DestNameLabel": "Name",
|
||||
"s3DestNamePlaceholder": "Mein S3-Ziel",
|
||||
"s3DestAccessKeyIdLabel": "AWS-Zugriffsschlüssel-ID",
|
||||
"s3DestSecretAccessKeyLabel": "AWS-Geheimzugriffsschlüssel",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Ihr AWS-Geheimzugriffsschlüssel",
|
||||
"s3DestRegionLabel": "AWS-Region",
|
||||
"s3DestBucketLabel": "Bucket-Name",
|
||||
"s3DestPrefixLabel": "Schlüssel-Präfix (optional)",
|
||||
"s3DestPrefixDescription": "Optionales Pfadpräfix, das jedem Objektschlüssel vorangestellt wird. Objekte werden unter {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename} gespeichert.",
|
||||
"s3DestEndpointLabel": "Benutzerdefinierter Endpunkt (optional)",
|
||||
"s3DestEndpointDescription": "Überschreiben Sie den S3-Endpunkt für S3-kompatiblen Speicher wie MinIO oder Cloudflare R2. Lassen Sie das Feld leer für standardmäßiges AWS S3.",
|
||||
"s3DestGzipLabel": "Gzip-Komprimierung",
|
||||
"s3DestGzipDescription": "Jedes hochgeladene Objekt mit Gzip komprimieren. Reduziert die Speicherkosten und die Upload-Größe.",
|
||||
"s3DestFormatTitle": "Dateiformat",
|
||||
"s3DestFormatDescription": "Wie Ereignisse in jedem hochgeladenen Objekt serialisiert werden.",
|
||||
"s3DestFormatJsonArrayDescription": "Jedes Objekt ist ein JSON-Array von Ereignisdaten. Kompatibel mit den meisten Analysetools.",
|
||||
"s3DestFormatNdjsonDescription": "Jedes Objekt enthält einen JSON-Datensatz pro Zeile (newline-delimited JSON). Kompatibel mit Athena, BigQuery und Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Jedes Objekt ist eine RFC-4180 CSV-Datei mit einer Kopfzeile. Spaltennamen werden aus den Ereignisdatenfeldern abgeleitet.",
|
||||
"s3DestSaveChanges": "Änderungen speichern",
|
||||
"s3DestCreateDestination": "Ziel erstellen",
|
||||
"s3DestUpdatedSuccess": "Ziel erfolgreich aktualisiert",
|
||||
"s3DestCreatedSuccess": "Ziel erfolgreich erstellt",
|
||||
"s3DestUpdateFailed": "Fehler beim Aktualisieren des Ziels",
|
||||
"s3DestCreateFailed": "Fehler beim Erstellen des Ziels",
|
||||
"datadogDestEditTitle": "Ziel bearbeiten",
|
||||
"datadogDestAddTitle": "Datadog-Ziel hinzufügen",
|
||||
"datadogDestEditDescription": "Konfiguration für dieses Datadog-Ereignis-Streamingziel aktualisieren.",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "An error occurred deleting link",
|
||||
"shareDeleted": "Link deleted",
|
||||
"shareDeletedDescription": "The link has been deleted",
|
||||
"shareDelete": "Delete Share Link",
|
||||
"shareDeleteConfirm": "Confirm Delete Share Link",
|
||||
"shareQuestionRemove": "Are you sure you want to delete this share link?",
|
||||
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
|
||||
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
|
||||
"accessToken": "Access Token",
|
||||
"usageExamples": "Usage Examples",
|
||||
@@ -204,11 +208,33 @@
|
||||
"resourcesSearch": "Search resources...",
|
||||
"resourceAdd": "Add Resource",
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
"resourcePoliciesTitle": "Manage Resource Policies",
|
||||
"resourcePoliciesAttachedResourcesColumnTitle": "Attached resources",
|
||||
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
||||
"resourcePoliciesAttachedResourcesEmpty": "no resources",
|
||||
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources",
|
||||
"resourcePoliciesSearch": "Search policies...",
|
||||
"resourcePoliciesAdd": "Add Policy",
|
||||
"resourcePoliciesDefaultBadgeText": "Default policy",
|
||||
"resourcePoliciesCreate": "Create Resource Policy",
|
||||
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
|
||||
"resourcePolicyName": "Policy Name",
|
||||
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
|
||||
"resourcePolicyNamePlaceholder": "e.g. Internal Access Policy",
|
||||
"resourcePoliciesSeeAll": "See All Policies",
|
||||
"resourcePolicyAuthMethodAdd": "Add Authentication Method",
|
||||
"resourcePolicyOtpEmailAdd": "Add OTP emails",
|
||||
"resourcePolicyRulesAdd": "Add Rules",
|
||||
"resourcePolicyAuthMethodsDescription": "Allow access to resources via additional auth methods",
|
||||
"resourcePolicyUsersRolesDescription": "Configure which users and roles can visit associated resources",
|
||||
"rulesResourcePolicyDescription": "Configure rules to control access resources associated to this policy",
|
||||
"authentication": "Authentication",
|
||||
"protected": "Protected",
|
||||
"notProtected": "Not Protected",
|
||||
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
|
||||
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
|
||||
"resourcePolicyMessageRemove": "Once removed, the resource policy will no longer be accessible. All resources associated with the resource will be unlinked and left without authentication.",
|
||||
"resourcePolicyQuestionRemove": "Are you sure you want to remove the resource policy from the organization?",
|
||||
"resourceHTTP": "HTTPS Resource",
|
||||
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
|
||||
"resourceRaw": "Raw TCP/UDP Resource",
|
||||
@@ -249,6 +275,8 @@
|
||||
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
|
||||
"resourceBack": "Back to Resources",
|
||||
"resourceGoTo": "Go to Resource",
|
||||
"resourcePolicyDelete": "Delete Resource Policy",
|
||||
"resourcePolicyDeleteConfirm": "Confirm Delete Resource Policy",
|
||||
"resourceDelete": "Delete Resource",
|
||||
"resourceDeleteConfirm": "Confirm Delete Resource",
|
||||
"visibility": "Visibility",
|
||||
@@ -261,6 +289,8 @@
|
||||
"rules": "Rules",
|
||||
"resourceSettingDescription": "Configure the settings on the resource",
|
||||
"resourceSetting": "{resourceName} Settings",
|
||||
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
|
||||
"resourcePolicySetting": "{policyName} Settings",
|
||||
"alwaysAllow": "Bypass Auth",
|
||||
"alwaysDeny": "Block Access",
|
||||
"passToAuth": "Pass to Auth",
|
||||
@@ -523,6 +553,12 @@
|
||||
"userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.",
|
||||
"userRemoveOrgConfirm": "Confirm Remove User",
|
||||
"userRemoveOrg": "Remove User from Organization",
|
||||
"userQuestionOrgRemoveSelf": "Are you sure you want to remove yourself from this organization?",
|
||||
"userMessageOrgRemoveSelf": "You will lose access immediately. An administrator can invite you again later, but you will need to accept a new invitation.",
|
||||
"userRemoveOrgConfirmSelf": "Confirm Remove Myself",
|
||||
"userRemoveOrgSelf": "Remove yourself from the organization",
|
||||
"userRemoveOrgSelfWarning": "You will lose access to this organization immediately.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "REMOVE MYSELF FROM ORG",
|
||||
"users": "Users",
|
||||
"accessRoleMember": "Member",
|
||||
"accessRoleOwner": "Owner",
|
||||
@@ -531,6 +567,11 @@
|
||||
"emailInvalid": "Invalid email address",
|
||||
"inviteValidityDuration": "Please select a duration",
|
||||
"accessRoleSelectPlease": "Please select a role",
|
||||
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
|
||||
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
|
||||
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
|
||||
"removeOwnAdminRoleConfirmPhrase": "REMOVE MY ADMIN ACCESS",
|
||||
"ownerMustRetainAdminRole": "The organization owner must keep at least one administrator role.",
|
||||
"usernameRequired": "Username is required",
|
||||
"idpSelectPlease": "Please select an identity provider",
|
||||
"idpGenericOidc": "Generic OAuth2/OIDC provider.",
|
||||
@@ -658,6 +699,7 @@
|
||||
"targetNoOneDescription": "Adding more than one target above will enable load balancing.",
|
||||
"targetsSubmit": "Save Targets",
|
||||
"addTarget": "Add Target",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Round robin routing will not work between sites that are not connected to the same node, but failover will work.",
|
||||
"targetErrorInvalidIp": "Invalid IP address",
|
||||
"targetErrorInvalidIpDescription": "Please enter a valid IP address or hostname",
|
||||
"targetErrorInvalidPort": "Invalid port",
|
||||
@@ -731,6 +773,16 @@
|
||||
"rulesNoOne": "No rules. Add a rule using the form.",
|
||||
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
||||
"rulesSubmit": "Save Rules",
|
||||
"policyErrorCreate": "Error creating policy",
|
||||
"policyErrorCreateDescription": "An error occurred when creating the policy",
|
||||
"policyErrorCreateMessageDescription": "An unexpected error occurred",
|
||||
"policyErrorUpdate": "Error updating policy",
|
||||
"policyErrorUpdateDescription": "An error occurred when updating the policy",
|
||||
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
|
||||
"policyCreatedSuccess": "Resource policy succesfully created",
|
||||
"policyUpdatedSuccess": "Resource policy succesfully updated",
|
||||
"authMethodsSave": "Save auth methods",
|
||||
"rulesSave": "Save Rules",
|
||||
"resourceErrorCreate": "Error creating resource",
|
||||
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
||||
"resourceErrorCreateMessage": "Error creating resource:",
|
||||
@@ -794,6 +846,16 @@
|
||||
"pincodeAdd": "Add PIN Code",
|
||||
"pincodeRemove": "Remove PIN Code",
|
||||
"resourceAuthMethods": "Authentication Methods",
|
||||
"resourcePolicyAuthMethodsEmpty": "No authentication method",
|
||||
"resourcePolicyOtpEmpty": "No one time password",
|
||||
"resourcePolicyReadOnly": "This policy is Read only",
|
||||
"resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it on this page.",
|
||||
"resourcePolicyTypeSave": "Save Resource type",
|
||||
"resourcePolicySelect": "Select resource policy",
|
||||
"resourcePolicySelectError": "Select a resource policy",
|
||||
"resourcePolicyNotFound": "Policy not found",
|
||||
"resourcePolicySearch": "Search policies",
|
||||
"resourcePolicyRulesEmpty": "No authentication rules",
|
||||
"resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods",
|
||||
"resourceAuthSettingsSave": "Saved successfully",
|
||||
"resourceAuthSettingsSaveDescription": "Authentication settings have been saved",
|
||||
@@ -829,6 +891,12 @@
|
||||
"resourcePincodeSetupTitle": "Set Pincode",
|
||||
"resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource",
|
||||
"resourceRoleDescription": "Admins can always access this resource.",
|
||||
"resourcePolicySelectTitle": "Resource Access Policy",
|
||||
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
|
||||
"resourcePolicyInline": "Inline Resource Policy",
|
||||
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
|
||||
"resourcePolicyShared": "Shared Resource Policy",
|
||||
"resourcePolicySharedDescription": "Access Policy shared accross multiple resources",
|
||||
"resourceUsersRoles": "Access Controls",
|
||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||
"resourceUsersRolesSubmit": "Save Access Controls",
|
||||
@@ -1358,6 +1426,8 @@
|
||||
"sidebarResources": "Resources",
|
||||
"sidebarProxyResources": "Public",
|
||||
"sidebarClientResources": "Private",
|
||||
"sidebarPolicies": "Policies",
|
||||
"sidebarResourcePolicies": "Resources",
|
||||
"sidebarAccessControl": "Access Control",
|
||||
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||
"sidebarTeam": "Team",
|
||||
@@ -2652,6 +2722,8 @@
|
||||
"validPassword": "Valid Password",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "View",
|
||||
"configManaged": "Config Managed",
|
||||
"connectedClient": "Connected Client",
|
||||
"resourceBlocked": "Resource Blocked",
|
||||
"droppedByRule": "Dropped by Rule",
|
||||
@@ -3062,7 +3134,7 @@
|
||||
"streamingDatadogTitle": "Datadog",
|
||||
"streamingDatadogDescription": "Forward events directly to your Datadog account.",
|
||||
"streamingTypePickerDescription": "Choose a destination type to get started.",
|
||||
"streamingFailedToLoad": "Failed to load destinations",
|
||||
"streamingLastSyncError": "An error occurred on the last sync",
|
||||
"streamingUnexpectedError": "An unexpected error occurred.",
|
||||
"streamingFailedToUpdate": "Failed to update destination",
|
||||
"streamingDeletedSuccess": "Destination deleted successfully",
|
||||
@@ -3079,7 +3151,34 @@
|
||||
"S3DestEditTitle": "Edit Destination",
|
||||
"S3DestAddTitle": "Add S3 Destination",
|
||||
"S3DestEditDescription": "Update the configuration for this S3 event streaming destination.",
|
||||
"S3DestAddDescription": "Configure a new S3 endpoint to receive your organization's events.",
|
||||
"S3DestAddDescription": "Configure a new Amazon S3 (or S3-compatible) bucket to receive your organization's events.",
|
||||
"s3DestTabSettings": "Settings",
|
||||
"s3DestTabFormat": "Format",
|
||||
"s3DestNameLabel": "Name",
|
||||
"s3DestNamePlaceholder": "My S3 destination",
|
||||
"s3DestAccessKeyIdLabel": "AWS Access Key ID",
|
||||
"s3DestSecretAccessKeyLabel": "AWS Secret Access Key",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Your AWS secret access key",
|
||||
"s3DestRegionLabel": "AWS Region",
|
||||
"s3DestBucketLabel": "Bucket Name",
|
||||
"s3DestPrefixLabel": "Key Prefix (optional)",
|
||||
"s3DestPrefixDescription": "Optional path prefix prepended to every object key. Objects are stored at {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Custom Endpoint (optional)",
|
||||
"s3DestEndpointDescription": "Override the S3 endpoint for S3-compatible storage such as MinIO or Cloudflare R2. Leave blank for standard AWS S3.",
|
||||
"s3DestGzipLabel": "Gzip compression",
|
||||
"s3DestGzipDescription": "Compress each uploaded object with gzip. Reduces storage costs and upload size.",
|
||||
"s3DestFormatTitle": "File Format",
|
||||
"s3DestFormatDescription": "How events are serialised inside each uploaded object.",
|
||||
"s3DestFormatJsonArrayDescription": "Each object is a JSON array of event records. Compatible with most analytics tools.",
|
||||
"s3DestFormatNdjsonDescription": "Each object contains one JSON record per line (newline-delimited JSON). Compatible with Athena, BigQuery, and Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Each object is an RFC-4180 CSV file with a header row. Column names are derived from the event data fields.",
|
||||
"s3DestSaveChanges": "Save Changes",
|
||||
"s3DestCreateDestination": "Create Destination",
|
||||
"s3DestUpdatedSuccess": "Destination updated successfully",
|
||||
"s3DestCreatedSuccess": "Destination created successfully",
|
||||
"s3DestUpdateFailed": "Failed to update destination",
|
||||
"s3DestCreateFailed": "Failed to create destination",
|
||||
"datadogDestEditTitle": "Edit Destination",
|
||||
"datadogDestAddTitle": "Add Datadog Destination",
|
||||
"datadogDestEditDescription": "Update the configuration for this Datadog event streaming destination.",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Se ha producido un error al eliminar el enlace",
|
||||
"shareDeleted": "Enlace eliminado",
|
||||
"shareDeletedDescription": "El enlace ha sido eliminado",
|
||||
"shareDelete": "Borrar Enlace Compartido",
|
||||
"shareDeleteConfirm": "Confirmar Borrado del Enlace Compartido",
|
||||
"shareQuestionRemove": "¿Está seguro de que desea borrar este enlace compartido?",
|
||||
"shareMessageRemove": "Una vez borrado, el enlace dejará de funcionar y cualquier persona que lo use perderá acceso al recurso.",
|
||||
"shareTokenDescription": "El token de acceso puede ser pasado de dos maneras: como parámetro de consulta o en las cabeceras de solicitud. Estos deben ser pasados del cliente en cada solicitud de acceso autenticado.",
|
||||
"accessToken": "Token de acceso",
|
||||
"usageExamples": "Ejemplos de uso",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "Una vez eliminado, este usuario ya no tendrá acceso a la organización. Siempre puede volver a invitarlos más tarde, pero tendrán que aceptar la invitación de nuevo.",
|
||||
"userRemoveOrgConfirm": "Confirmar eliminar usuario",
|
||||
"userRemoveOrg": "Eliminar usuario de la organización",
|
||||
"userQuestionOrgRemoveSelf": "¿Está seguro de que desea eliminarse de esta organización?",
|
||||
"userMessageOrgRemoveSelf": "Perderá acceso inmediatamente. Un administrador puede invitarlo de nuevo más tarde, pero necesitará aceptar una nueva invitación.",
|
||||
"userRemoveOrgConfirmSelf": "Confirmar Eliminarme",
|
||||
"userRemoveOrgSelf": "Eliminarse de la organización",
|
||||
"userRemoveOrgSelfWarning": "Perderá acceso a esta organización inmediatamente.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "ELIMINARME DE LA ORGANIZACIÓN",
|
||||
"users": "Usuarios",
|
||||
"accessRoleMember": "Miembro",
|
||||
"accessRoleOwner": "Propietario",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Dirección de correo inválida",
|
||||
"inviteValidityDuration": "Por favor, seleccione una duración",
|
||||
"accessRoleSelectPlease": "Por favor, seleccione un rol",
|
||||
"removeOwnAdminRoleConfirmTitle": "¿Eliminar su acceso de administrador?",
|
||||
"removeOwnAdminRoleConfirmDescription": "Ya no tendrá permisos de administrador en esta organización después de guardar. Otro administrador puede restaurar el acceso si es necesario.",
|
||||
"removeOwnAdminRoleConfirmButton": "Eliminar Mi Acceso de Administrador",
|
||||
"removeOwnAdminRoleConfirmPhrase": "ELIMINAR MI ACCESO DE ADMINISTRADOR",
|
||||
"ownerMustRetainAdminRole": "El propietario de la organización debe mantener al menos un rol de administrador.",
|
||||
"usernameRequired": "Nombre de usuario requerido",
|
||||
"idpSelectPlease": "Por favor, seleccione un proveedor de identidad",
|
||||
"idpGenericOidc": "Proveedor OAuth2/OIDC genérico.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "Si se añade más de un objetivo anterior se activará el balance de carga.",
|
||||
"targetsSubmit": "Guardar objetivos",
|
||||
"addTarget": "Añadir destino",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "El enrutamiento de turnos no funcionará entre sitios que no están conectados al mismo nodo, pero el failover funcionará.",
|
||||
"targetErrorInvalidIp": "Dirección IP inválida",
|
||||
"targetErrorInvalidIpDescription": "Por favor, introduzca una dirección IP válida o nombre de host",
|
||||
"targetErrorInvalidPort": "Puerto inválido",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Contraseña válida",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "Ver",
|
||||
"configManaged": "Configuración Gestionada",
|
||||
"connectedClient": "Cliente conectado",
|
||||
"resourceBlocked": "Recurso bloqueado",
|
||||
"droppedByRule": "Soltado por regla",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"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",
|
||||
"streamingLastSyncError": "Ocurrió un error en la última sincronización.",
|
||||
"streamingUnexpectedError": "Se ha producido un error inesperado.",
|
||||
"streamingFailedToUpdate": "Error al actualizar destino",
|
||||
"streamingDeletedSuccess": "Destino eliminado correctamente",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Editar destino",
|
||||
"S3DestAddTitle": "Añadir destino S3",
|
||||
"S3DestEditDescription": "Actualice la configuración para este destino de transmisión de eventos S3.",
|
||||
"S3DestAddDescription": "Configure un nuevo punto final S3 para recibir los eventos de su organización.",
|
||||
"S3DestAddDescription": "Configura un nuevo bucket de Amazon S3 (o compatible con S3) para recibir los eventos de tu organización.",
|
||||
"s3DestTabSettings": "Ajustes",
|
||||
"s3DestTabFormat": "Formato",
|
||||
"s3DestNameLabel": "Nombre",
|
||||
"s3DestNamePlaceholder": "Mi destino S3",
|
||||
"s3DestAccessKeyIdLabel": "ID de clave de acceso de AWS",
|
||||
"s3DestSecretAccessKeyLabel": "Clave de acceso secreta de AWS",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Tu clave de acceso secreta de AWS",
|
||||
"s3DestRegionLabel": "Región de AWS",
|
||||
"s3DestBucketLabel": "Nombre del bucket",
|
||||
"s3DestPrefixLabel": "Prefijo clave (opcional)",
|
||||
"s3DestPrefixDescription": "Prefijo de ruta opcional preanexado a cada clave de objeto. Los objetos se almacenan en {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Punto final personalizado (opcional)",
|
||||
"s3DestEndpointDescription": "Sobrescribe el punto final de S3 para almacenamiento compatible con S3 como MinIO o Cloudflare R2. Deja en blanco para el estándar AWS S3.",
|
||||
"s3DestGzipLabel": "Compresión Gzip",
|
||||
"s3DestGzipDescription": "Comprime cada objeto subido con gzip. Reduce costos de almacenamiento y tamaño de carga.",
|
||||
"s3DestFormatTitle": "Formato de archivo",
|
||||
"s3DestFormatDescription": "Cómo se serializan los eventos dentro de cada objeto cargado.",
|
||||
"s3DestFormatJsonArrayDescription": "Cada objeto es un arreglo JSON de registros de eventos. Compatible con la mayoría de las herramientas de analítica.",
|
||||
"s3DestFormatNdjsonDescription": "Cada objeto contiene un registro JSON por línea (JSON delimitado por nueva línea). Compatible con Athena, BigQuery y Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Cada objeto es un archivo CSV conforme a RFC-4180 con una fila de encabezado. Los nombres de columna se derivan de los campos de datos del evento.",
|
||||
"s3DestSaveChanges": "Guardar cambios",
|
||||
"s3DestCreateDestination": "Crear destino",
|
||||
"s3DestUpdatedSuccess": "Destino actualizado con éxito",
|
||||
"s3DestCreatedSuccess": "Destino creado con éxito",
|
||||
"s3DestUpdateFailed": "No se pudo actualizar el destino",
|
||||
"s3DestCreateFailed": "No se pudo crear el destino",
|
||||
"datadogDestEditTitle": "Editar destino",
|
||||
"datadogDestAddTitle": "Añadir destino Datadog",
|
||||
"datadogDestEditDescription": "Actualice la configuración para este destino de transmisión de eventos Datadog.",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Une erreur s'est produite lors de la suppression du lien",
|
||||
"shareDeleted": "Lien supprimé",
|
||||
"shareDeletedDescription": "Le lien a été supprimé",
|
||||
"shareDelete": "Supprimer le lien de partage",
|
||||
"shareDeleteConfirm": "Confirmer la suppression du lien de partage",
|
||||
"shareQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce lien de partage ?",
|
||||
"shareMessageRemove": "Une fois supprimé, le lien ne fonctionnera plus et toute personne l'utilisant perdra l'accès à la ressource.",
|
||||
"shareTokenDescription": "Le jeton d'accès peut être passé de deux façons : en tant que paramètre de requête ou dans les en-têtes de la requête. Elles doivent être transmises par le client à chaque demande d'accès authentifié.",
|
||||
"accessToken": "Jeton d'accès",
|
||||
"usageExamples": "Exemples d'utilisation",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "Une fois retiré, cet utilisateur n'aura plus accès à l'organisation. Vous pouvez toujours le réinviter plus tard, mais il devra accepter l'invitation à nouveau.",
|
||||
"userRemoveOrgConfirm": "Confirmer la suppression de l'utilisateur",
|
||||
"userRemoveOrg": "Retirer l'utilisateur de l'organisation",
|
||||
"userQuestionOrgRemoveSelf": "Êtes-vous sûr de vouloir vous retirer de cette organisation ?",
|
||||
"userMessageOrgRemoveSelf": "Vous perdrez immédiatement l'accès. Un administrateur pourra vous inviter à nouveau plus tard, mais vous devrez accepter une nouvelle invitation.",
|
||||
"userRemoveOrgConfirmSelf": "Confirmer la suppression de moi-même",
|
||||
"userRemoveOrgSelf": "Se retirer de l'organisation",
|
||||
"userRemoveOrgSelfWarning": "Vous perdrez immédiatement l'accès à cette organisation.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "SUPPRIMER MOI-MÊME DE L'ORG",
|
||||
"users": "Utilisateurs",
|
||||
"accessRoleMember": "Membre",
|
||||
"accessRoleOwner": "Propriétaire",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Adresse e-mail invalide",
|
||||
"inviteValidityDuration": "Veuillez sélectionner une durée",
|
||||
"accessRoleSelectPlease": "Veuillez sélectionner un rôle",
|
||||
"removeOwnAdminRoleConfirmTitle": "Retirer votre accès administrateur ?",
|
||||
"removeOwnAdminRoleConfirmDescription": "Vous n'aurez plus de droits d'administrateur dans cette organisation après avoir enregistré. Un autre administrateur pourra restaurer cet accès si nécessaire.",
|
||||
"removeOwnAdminRoleConfirmButton": "Retirer mon accès administrateur",
|
||||
"removeOwnAdminRoleConfirmPhrase": "RETIRER MON ACCÈS ADMIN",
|
||||
"ownerMustRetainAdminRole": "Le propriétaire de l'organisation doit conserver au moins un rôle d'administrateur.",
|
||||
"usernameRequired": "Le nom d'utilisateur est requis",
|
||||
"idpSelectPlease": "Veuillez sélectionner un fournisseur d'identité",
|
||||
"idpGenericOidc": "Fournisseur OAuth2/OIDC générique.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "L'ajout de plus d'une cible ci-dessus activera l'équilibrage de charge.",
|
||||
"targetsSubmit": "Enregistrer les cibles",
|
||||
"addTarget": "Ajouter une cible",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Le routage en tourniquet n'opérera pas entre des sites qui ne sont pas connectés au même nœud, mais le basculement fonctionnera.",
|
||||
"targetErrorInvalidIp": "Adresse IP invalide",
|
||||
"targetErrorInvalidIpDescription": "Veuillez entrer une adresse IP ou un nom d'hôte valide",
|
||||
"targetErrorInvalidPort": "Port invalide",
|
||||
@@ -1356,7 +1372,7 @@
|
||||
"sidebarSites": "Nœuds",
|
||||
"sidebarApprovals": "Demandes d'approbation",
|
||||
"sidebarResources": "Ressource",
|
||||
"sidebarProxyResources": "Publiques",
|
||||
"sidebarProxyResources": "Publique",
|
||||
"sidebarClientResources": "Privé",
|
||||
"sidebarAccessControl": "Contrôle d'accès",
|
||||
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
|
||||
@@ -2458,8 +2474,8 @@
|
||||
"manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources",
|
||||
"downloadClientBannerTitle": "Télécharger le client Pangolin",
|
||||
"downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.",
|
||||
"manageMachineClients": "Gérer les machines",
|
||||
"manageMachineClientsDescription": "Créer et gérer les clients que les serveurs et systèmes utilisent pour se connecter en privé aux ressources",
|
||||
"manageMachineClients": "Gérer les clients de la machine",
|
||||
"manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources",
|
||||
"machineClientsBannerTitle": "Serveurs & Systèmes automatisés",
|
||||
"machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.",
|
||||
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Mot de passe valide",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "Afficher",
|
||||
"configManaged": "Configuration gérée",
|
||||
"connectedClient": "Client connecté",
|
||||
"resourceBlocked": "Ressource bloquée",
|
||||
"droppedByRule": "Abandonné par la règle",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"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",
|
||||
"streamingLastSyncError": "Une erreur s'est produite lors de la dernière synchronisation",
|
||||
"streamingUnexpectedError": "Une erreur inattendue s'est produite.",
|
||||
"streamingFailedToUpdate": "Impossible de mettre à jour la destination",
|
||||
"streamingDeletedSuccess": "Destination supprimée avec succès",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Modifier la destination",
|
||||
"S3DestAddTitle": "Ajouter une destination S3",
|
||||
"S3DestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements S3.",
|
||||
"S3DestAddDescription": "Configurer un nouveau point de terminaison S3 pour recevoir les événements de votre organisation.",
|
||||
"S3DestAddDescription": "Configurez un nouveau bucket Amazon S3 (ou compatible S3) pour recevoir les événements de votre organisation.",
|
||||
"s3DestTabSettings": "Réglages",
|
||||
"s3DestTabFormat": "Format",
|
||||
"s3DestNameLabel": "Nom",
|
||||
"s3DestNamePlaceholder": "Ma destination S3",
|
||||
"s3DestAccessKeyIdLabel": "ID de clé d'accès AWS",
|
||||
"s3DestSecretAccessKeyLabel": "Clé d'accès secrète AWS",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Votre clé d'accès secrète AWS",
|
||||
"s3DestRegionLabel": "Région AWS",
|
||||
"s3DestBucketLabel": "Nom du bucket",
|
||||
"s3DestPrefixLabel": "Préfixe clé (facultatif)",
|
||||
"s3DestPrefixDescription": "Préfixe de chemin facultatif préfixé à chaque clé d'objet. Les objets sont stockés à {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Point de terminaison personnalisé (facultatif)",
|
||||
"s3DestEndpointDescription": "Modifiez le point de terminaison S3 pour un stockage compatible S3 tel que MinIO ou Cloudflare R2. Laissez vide pour l'AWS S3 standard.",
|
||||
"s3DestGzipLabel": "Compression Gzip",
|
||||
"s3DestGzipDescription": "Compressez chaque objet téléchargé avec gzip. Réduit les coûts de stockage et la taille de téléchargement.",
|
||||
"s3DestFormatTitle": "Format de fichier",
|
||||
"s3DestFormatDescription": "Comment les événements sont sérialisés dans chaque objet téléchargé.",
|
||||
"s3DestFormatJsonArrayDescription": "Chaque objet est un tableau JSON des enregistrements d'événements. Compatible avec la plupart des outils d'analyse.",
|
||||
"s3DestFormatNdjsonDescription": "Chaque objet contient un enregistrement JSON par ligne (JSON délimité par saut de ligne). Compatible avec Athena, BigQuery et Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Chaque objet est un fichier CSV RFC-4180 avec une ligne d'en-tête. Les noms de colonne sont dérivés des champs de données de l'événement.",
|
||||
"s3DestSaveChanges": "Enregistrer les modifications",
|
||||
"s3DestCreateDestination": "Créer une destination",
|
||||
"s3DestUpdatedSuccess": "Destination mise à jour avec succès",
|
||||
"s3DestCreatedSuccess": "Destination créée avec succès",
|
||||
"s3DestUpdateFailed": "Échec de la mise à jour de la destination",
|
||||
"s3DestCreateFailed": "Échec de la création de la destination",
|
||||
"datadogDestEditTitle": "Modifier la destination",
|
||||
"datadogDestAddTitle": "Ajouter une destination Datadog",
|
||||
"datadogDestEditDescription": "Mettre à jour la configuration de cette destination de diffusion d'événements Datadog.",
|
||||
@@ -3154,7 +3199,6 @@
|
||||
"healthCheckTabAdvanced": "Avancé",
|
||||
"healthCheckStrategyNotAvailable": "Cette stratégie n'est pas disponible. Veuillez contacter le service commercial pour activer cette fonctionnalité.",
|
||||
"uptime30d": "Disponibilité (30j)",
|
||||
"uptimeNoData": "Aucune donnée",
|
||||
"idpAddActionCreateNew": "Créer un nouveau fournisseur d'identité",
|
||||
"idpAddActionImportFromOrg": "Importer d'une autre organisation",
|
||||
"idpImportDialogTitle": "Importer le fournisseur d'identité",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Si è verificato un errore durante l'eliminazione del link",
|
||||
"shareDeleted": "Link eliminato",
|
||||
"shareDeletedDescription": "Il link è stato eliminato",
|
||||
"shareDelete": "Elimina Link di Condivisione",
|
||||
"shareDeleteConfirm": "Conferma Eliminazione Link di Condivisione",
|
||||
"shareQuestionRemove": "Sei sicuro di voler eliminare questo link di condivisione?",
|
||||
"shareMessageRemove": "Una volta eliminato, il link non funzionerà più e chiunque lo utilizzi perderà l'accesso alla risorsa.",
|
||||
"shareTokenDescription": "Il token di accesso può essere passato in due modi: come parametro di interrogazione o nelle intestazioni della richiesta. Questi devono essere passati dal client su ogni richiesta di accesso autenticato.",
|
||||
"accessToken": "Token Di Accesso",
|
||||
"usageExamples": "Esempi Di Utilizzo",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "Una volta rimosso questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.",
|
||||
"userRemoveOrgConfirm": "Conferma Rimozione Utente",
|
||||
"userRemoveOrg": "Rimuovi Utente dall'Organizzazione",
|
||||
"userQuestionOrgRemoveSelf": "Sei sicuro di voler rimuovere te stesso da questa organizzazione?",
|
||||
"userMessageOrgRemoveSelf": "Perderai immediatamente l'accesso. Un amministratore può invitarti nuovamente in seguito, ma dovrai accettare un nuovo invito.",
|
||||
"userRemoveOrgConfirmSelf": "Conferma Rimozione Me Stesso",
|
||||
"userRemoveOrgSelf": "Rimuoviti dall'organizzazione",
|
||||
"userRemoveOrgSelfWarning": "Perderai immediatamente l'accesso a questa organizzazione.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "RIMUOVITI DALL'ORGANIZZAZIONE",
|
||||
"users": "Utenti",
|
||||
"accessRoleMember": "Membro",
|
||||
"accessRoleOwner": "Proprietario",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Indirizzo email non valido",
|
||||
"inviteValidityDuration": "Seleziona una durata",
|
||||
"accessRoleSelectPlease": "Seleziona un ruolo",
|
||||
"removeOwnAdminRoleConfirmTitle": "Rimuovere il tuo accesso amministrativo?",
|
||||
"removeOwnAdminRoleConfirmDescription": "Non avrai più i permessi di amministratore in questa organizzazione dopo il salvataggio. Un altro amministratore può ripristinare l'accesso se necessario.",
|
||||
"removeOwnAdminRoleConfirmButton": "Rimuovere il Mio Accesso Amministrativo",
|
||||
"removeOwnAdminRoleConfirmPhrase": "RIMUOVERE IL MIO ACCESSO AMMINISTRATIVO",
|
||||
"ownerMustRetainAdminRole": "Il proprietario dell'organizzazione deve mantenere almeno un ruolo di amministratore.",
|
||||
"usernameRequired": "Username richiesto",
|
||||
"idpSelectPlease": "Seleziona un provider di identità",
|
||||
"idpGenericOidc": "Provider OAuth2/OIDC generico.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "L'aggiunta di più di un target abiliterà il bilanciamento del carico.",
|
||||
"targetsSubmit": "Salva Target",
|
||||
"addTarget": "Aggiungi Target",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Il routing round robin non funzionerà tra siti che non sono connessi allo stesso nodo, ma il failover funzionerà.",
|
||||
"targetErrorInvalidIp": "Indirizzo IP non valido",
|
||||
"targetErrorInvalidIpDescription": "Inserisci un indirizzo IP o un hostname valido",
|
||||
"targetErrorInvalidPort": "Porta non valida",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Password Valida",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "Visualizza",
|
||||
"configManaged": "Gestione Configurazione",
|
||||
"connectedClient": "Cliente Connesso",
|
||||
"resourceBlocked": "Risorsa Bloccata",
|
||||
"droppedByRule": "Eliminato dalla regola",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"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",
|
||||
"streamingLastSyncError": "Si è verificato un errore durante l'ultima sincronizzazione",
|
||||
"streamingUnexpectedError": "Si è verificato un errore imprevisto.",
|
||||
"streamingFailedToUpdate": "Impossibile aggiornare la destinazione",
|
||||
"streamingDeletedSuccess": "Destinazione eliminata con successo",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Modifica Destinazione",
|
||||
"S3DestAddTitle": "Aggiungi Destinazione S3",
|
||||
"S3DestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi S3.",
|
||||
"S3DestAddDescription": "Configura un nuovo endpoint S3 per ricevere gli eventi della tua organizzazione.",
|
||||
"S3DestAddDescription": "Configura un nuovo bucket Amazon S3 (o compatibile con S3) per ricevere gli eventi della tua organizzazione.",
|
||||
"s3DestTabSettings": "Impostazioni",
|
||||
"s3DestTabFormat": "Formato",
|
||||
"s3DestNameLabel": "Nome",
|
||||
"s3DestNamePlaceholder": "La mia destinazione S3",
|
||||
"s3DestAccessKeyIdLabel": "ID Chiave Accesso AWS",
|
||||
"s3DestSecretAccessKeyLabel": "Chiave Segreta Accesso AWS",
|
||||
"s3DestSecretAccessKeyPlaceholder": "La tua chiave segreta di accesso AWS",
|
||||
"s3DestRegionLabel": "Regione AWS",
|
||||
"s3DestBucketLabel": "Nome Bucket",
|
||||
"s3DestPrefixLabel": "Prefisso Chiave (facoltativo)",
|
||||
"s3DestPrefixDescription": "Prefisso percorso facoltativo anteposto a ogni chiave oggetto. Gli oggetti vengono archiviati in {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Endpoint personalizzato (facoltativo)",
|
||||
"s3DestEndpointDescription": "Sostituisci l'endpoint S3 per lo storage compatibile con S3 come MinIO o Cloudflare R2. Lasciare vuoto per l'AWS S3 standard.",
|
||||
"s3DestGzipLabel": "Compressione Gzip",
|
||||
"s3DestGzipDescription": "Comprimi ogni oggetto caricato con gzip. Riduce i costi di archiviazione e la dimensione di caricamento.",
|
||||
"s3DestFormatTitle": "Formato del File",
|
||||
"s3DestFormatDescription": "Come gli eventi sono serializzati all'interno di ciascun oggetto caricato.",
|
||||
"s3DestFormatJsonArrayDescription": "Ogni oggetto è un array JSON di record di eventi. Compatibile con la maggior parte degli strumenti analitici.",
|
||||
"s3DestFormatNdjsonDescription": "Ogni oggetto contiene un record JSON per linea (JSON delimitato da newline). Compatibile con Athena, BigQuery e Spark.",
|
||||
"s3DestFormatCsvTitle": "\"CSV\"",
|
||||
"s3DestFormatCsvDescription": "Ogni oggetto è un file CSV RFC-4180 con una riga di intestazione. I nomi delle colonne sono derivati dai campi dei dati degli eventi.",
|
||||
"s3DestSaveChanges": "Salva modifiche",
|
||||
"s3DestCreateDestination": "Crea destinazione",
|
||||
"s3DestUpdatedSuccess": "Destinazione aggiornata con successo",
|
||||
"s3DestCreatedSuccess": "Destinazione creata con successo",
|
||||
"s3DestUpdateFailed": "Aggiornamento della destinazione fallito",
|
||||
"s3DestCreateFailed": "Creazione della destinazione fallita",
|
||||
"datadogDestEditTitle": "Modifica Destinazione",
|
||||
"datadogDestAddTitle": "Aggiungi Destinazione Datadog",
|
||||
"datadogDestEditDescription": "Aggiorna la configurazione per questa destinazione di streaming eventi Datadog.",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "링크 삭제 중 오류가 발생했습니다.",
|
||||
"shareDeleted": "링크가 삭제되었습니다.",
|
||||
"shareDeletedDescription": "링크가 삭제되었습니다.",
|
||||
"shareDelete": "공유 링크 삭제",
|
||||
"shareDeleteConfirm": "공유 링크 삭제 확인",
|
||||
"shareQuestionRemove": "이 공유 링크를 삭제하시겠습니까?",
|
||||
"shareMessageRemove": "삭제되면 링크가 더 이상 작동하지 않으며, 이를 사용하는 모든 사용자는 자원에 대한 접근을 잃게 됩니다.",
|
||||
"shareTokenDescription": "액세스 토큰은 쿼리 매개변수 또는 요청 헤더의 두 가지 방법으로 전달될 수 있습니다. 이는 인증된 액세스를 위해 클라이언트에서 모든 요청마다 전달되어야 합니다.",
|
||||
"accessToken": "액세스 토큰",
|
||||
"usageExamples": "사용 예",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "이 사용자가 제거되면 더 이상 조직에 접근할 수 없습니다. 나중에 다시 초대할 수 있지만, 초대를 다시 수락해야 합니다.",
|
||||
"userRemoveOrgConfirm": "사용자 제거 확인",
|
||||
"userRemoveOrg": "조직에서 사용자 제거",
|
||||
"userQuestionOrgRemoveSelf": "이 조직에서 자신을 제거하시겠습니까?",
|
||||
"userMessageOrgRemoveSelf": "귀하는 즉시 접근 권한을 잃게 됩니다. 관리자가 나중에 다시 초대할 수 있지만, 새 초대를 수락해야 합니다.",
|
||||
"userRemoveOrgConfirmSelf": "내 제거 확인",
|
||||
"userRemoveOrgSelf": "조직에서 자신을 제거하십시오",
|
||||
"userRemoveOrgSelfWarning": "귀하는 이 조직에 대한 접근 권한을 즉시 상실합니다.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "조직에서 나를 제거",
|
||||
"users": "사용자",
|
||||
"accessRoleMember": "회원",
|
||||
"accessRoleOwner": "소유자",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "유효하지 않은 이메일 주소입니다.",
|
||||
"inviteValidityDuration": "지속 시간을 선택하십시오.",
|
||||
"accessRoleSelectPlease": "역할을 선택하세요",
|
||||
"removeOwnAdminRoleConfirmTitle": "관리자 권한을 제거하시겠습니까?",
|
||||
"removeOwnAdminRoleConfirmDescription": "저장 후 이 조직에 대한 관리자 권한이 없어집니다. 필요한 경우 다른 관리자가 접근 권한을 복구할 수 있습니다.",
|
||||
"removeOwnAdminRoleConfirmButton": "내 관리자 권한 제거",
|
||||
"removeOwnAdminRoleConfirmPhrase": "내 관리자 권한 제거",
|
||||
"ownerMustRetainAdminRole": "조직 소유자는 최소한 하나의 관리자 역할을 유지해야 합니다.",
|
||||
"usernameRequired": "사용자 이름은 필수입니다.",
|
||||
"idpSelectPlease": "신원 제공자를 선택하십시오",
|
||||
"idpGenericOidc": "일반 OAuth2/OIDC 공급자.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "위에 하나 이상의 대상을 추가하면 로드 밸런싱이 활성화됩니다.",
|
||||
"targetsSubmit": "대상 저장",
|
||||
"addTarget": "대상 추가",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "라운드 로빈 라우팅은 동일한 노드에 연결되지 않은 사이트 간에는 작동하지 않으나, 대체 라우팅은 작동합니다.",
|
||||
"targetErrorInvalidIp": "유효하지 않은 IP 주소",
|
||||
"targetErrorInvalidIpDescription": "유효한 IP 주소 또는 호스트 이름을 입력하세요.",
|
||||
"targetErrorInvalidPort": "유효하지 않은 포트",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "유효한 비밀번호",
|
||||
"validEmail": "유효한 이메일",
|
||||
"validSSO": "유효한 SSO",
|
||||
"view": "보기",
|
||||
"configManaged": "구성 관리됨",
|
||||
"connectedClient": "연결된 클라이언트",
|
||||
"resourceBlocked": "리소스 차단됨",
|
||||
"droppedByRule": "룰에 의해 드롭됨",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"streamingDatadogTitle": "데이터독",
|
||||
"streamingDatadogDescription": "이벤트를 직접 Datadog 계정으로 전달합니다. 곧 제공됩니다.",
|
||||
"streamingTypePickerDescription": "목표 유형을 선택하여 시작합니다.",
|
||||
"streamingFailedToLoad": "대상 로드에 실패했습니다",
|
||||
"streamingLastSyncError": "마지막 동기화에서 오류가 발생했습니다.",
|
||||
"streamingUnexpectedError": "예기치 않은 오류가 발생했습니다.",
|
||||
"streamingFailedToUpdate": "대상지를 업데이트하는 데 실패했습니다",
|
||||
"streamingDeletedSuccess": "대상지가 성공적으로 삭제되었습니다",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "대상지 수정",
|
||||
"S3DestAddTitle": "S3 대상지 추가",
|
||||
"S3DestEditDescription": "이 S3 이벤트 스트리밍 대상지의 구성을 업데이트하세요.",
|
||||
"S3DestAddDescription": "조직의 이벤트를 받기 위한 새로운 S3 엔드포인트를 구성하세요.",
|
||||
"S3DestAddDescription": "조직의 이벤트를 수신할 새로운 Amazon S3(또는 S3 호환) 버킷을 구성하세요.",
|
||||
"s3DestTabSettings": "설정",
|
||||
"s3DestTabFormat": "형식",
|
||||
"s3DestNameLabel": "이름",
|
||||
"s3DestNamePlaceholder": "내 S3 대상",
|
||||
"s3DestAccessKeyIdLabel": "AWS 액세스 키 ID",
|
||||
"s3DestSecretAccessKeyLabel": "AWS 비밀 액세스 키",
|
||||
"s3DestSecretAccessKeyPlaceholder": "귀하의 AWS 비밀 액세스 키",
|
||||
"s3DestRegionLabel": "AWS 지역",
|
||||
"s3DestBucketLabel": "버킷 이름",
|
||||
"s3DestPrefixLabel": "키 접두사(선택 사항)",
|
||||
"s3DestPrefixDescription": "하나의 객체 키 앞에 붙이는 선택적 경로 접두사입니다. 객체는 {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}에 저장됩니다.",
|
||||
"s3DestEndpointLabel": "사용자 정의 엔드포인트(선택 사항)",
|
||||
"s3DestEndpointDescription": "MinIO 또는 Cloudflare R2와 같은 S3 호환 저장소에 대한 S3 엔드포인트를 재정의합니다. 표준 AWS S3의 경우 비워 두십시오.",
|
||||
"s3DestGzipLabel": "Gzip 압축",
|
||||
"s3DestGzipDescription": "각 업로드된 객체를 gzip으로 압축합니다. 저장 비용과 업로드 크기를 줄입니다.",
|
||||
"s3DestFormatTitle": "파일 형식",
|
||||
"s3DestFormatDescription": "업로드된 각 객체 내에서 이벤트가 직렬화되는 방식입니다.",
|
||||
"s3DestFormatJsonArrayDescription": "각 객체는 이벤트 기록의 JSON 배열입니다. 대부분의 분석 도구와 호환됩니다.",
|
||||
"s3DestFormatNdjsonDescription": "각 객체는 한 줄당 하나의 JSON 레코드를 포함합니다(새 줄로 구분된 JSON). Athena, BigQuery, Spark와 호환됩니다.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "각 객체는 헤더 행이 있는 RFC-4180 CSV 파일입니다. 열 이름은 이벤트 데이터 필드에서 파생됩니다.",
|
||||
"s3DestSaveChanges": "변경 사항 저장",
|
||||
"s3DestCreateDestination": "대상 생성",
|
||||
"s3DestUpdatedSuccess": "대상이 성공적으로 업데이트되었습니다",
|
||||
"s3DestCreatedSuccess": "대상이 성공적으로 생성되었습니다",
|
||||
"s3DestUpdateFailed": "대상 업데이트에 실패했습니다",
|
||||
"s3DestCreateFailed": "대상 생성에 실패했습니다",
|
||||
"datadogDestEditTitle": "대상지 수정",
|
||||
"datadogDestAddTitle": "Datadog 대상지 추가",
|
||||
"datadogDestEditDescription": "이 Datadog 이벤트 스트리밍 대상지의 구성을 업데이트하세요.",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "En feil oppstod ved sletting av lenke",
|
||||
"shareDeleted": "Lenke slettet",
|
||||
"shareDeletedDescription": "Lenken har blitt slettet",
|
||||
"shareDelete": "Slett delingslenke",
|
||||
"shareDeleteConfirm": "Bekreft sletting av delingslenke",
|
||||
"shareQuestionRemove": "Er du sikker på at du vil slette denne delingslenken?",
|
||||
"shareMessageRemove": "Når slettet, vil lenken ikke lenger fungere, og alle som bruker den vil miste tilgang til ressursen.",
|
||||
"shareTokenDescription": "Adgangstoken kan sendes på to måter: som en spørringsparameter eller i forespørselsoverskriftene. Disse må sendes fra klienten på hver forespørsel om autentisert tilgang.",
|
||||
"accessToken": "Tilgangsnøkkel",
|
||||
"usageExamples": "Brukseksempler",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "Når denne brukeren er fjernet, vil de ikke lenger ha tilgang til organisasjonen. Du kan alltid invitere dem på nytt senere, men de vil måtte godta invitasjonen på nytt.",
|
||||
"userRemoveOrgConfirm": "Bekreft fjerning av bruker",
|
||||
"userRemoveOrg": "Fjern bruker fra organisasjon",
|
||||
"userQuestionOrgRemoveSelf": "Er du sikker på at du vil fjerne deg selv fra denne organisasjonen?",
|
||||
"userMessageOrgRemoveSelf": "Du vil miste tilgang umiddelbart. En administrator kan invitere deg igjen senere, men du må godta en ny invitasjon.",
|
||||
"userRemoveOrgConfirmSelf": "Bekreft fjerning av meg selv",
|
||||
"userRemoveOrgSelf": "Fjern deg selv fra organisasjonen",
|
||||
"userRemoveOrgSelfWarning": "Du vil miste tilgangen til denne organisasjonen umiddelbart.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "FJERN MEG SELV FRA ORG",
|
||||
"users": "Brukere",
|
||||
"accessRoleMember": "Medlem",
|
||||
"accessRoleOwner": "Eier",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Ugyldig e-postadresse",
|
||||
"inviteValidityDuration": "Vennligst velg en varighet",
|
||||
"accessRoleSelectPlease": "Vennligst velg en rolle",
|
||||
"removeOwnAdminRoleConfirmTitle": "Fjern din administratoradgang?",
|
||||
"removeOwnAdminRoleConfirmDescription": "Du vil ikke lenger ha administratorrettigheter i denne organisasjonen etter lagring. En annen administrator kan gjenopprette tilgang hvis nødvendig.",
|
||||
"removeOwnAdminRoleConfirmButton": "Fjern min administratoradgang",
|
||||
"removeOwnAdminRoleConfirmPhrase": "FJERN MIN ADMINISTRATORADGANG",
|
||||
"ownerMustRetainAdminRole": "Organisasjonseier må beholde minst én administratorrolle.",
|
||||
"usernameRequired": "Brukernavn er påkrevd",
|
||||
"idpSelectPlease": "Vennligst velg en identitetsleverandør",
|
||||
"idpGenericOidc": "Generisk OAuth2/OIDC-leverandør.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "Å legge til mer enn ett mål ovenfor vil aktivere lastbalansering.",
|
||||
"targetsSubmit": "Lagre mål",
|
||||
"addTarget": "Legg til mål",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Rundkjøringrutefordeling vil ikke fungere mellom steder som ikke er koblet til samme node, men failover vil fungere.",
|
||||
"targetErrorInvalidIp": "Ugyldig IP-adresse",
|
||||
"targetErrorInvalidIpDescription": "Skriv inn en gyldig IP-adresse eller vertsnavn",
|
||||
"targetErrorInvalidPort": "Ugyldig port",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Gyldig passord",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "Vis",
|
||||
"configManaged": "Konfigurasjon administrert",
|
||||
"connectedClient": "Tilkoblet klient",
|
||||
"resourceBlocked": "Ressurs blokkert",
|
||||
"droppedByRule": "Legg i regelen",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"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",
|
||||
"streamingLastSyncError": "Det oppstod en feil under siste synkronisering",
|
||||
"streamingUnexpectedError": "En uventet feil oppstod.",
|
||||
"streamingFailedToUpdate": "Kunne ikke oppdatere destinasjon",
|
||||
"streamingDeletedSuccess": "Målet ble slettet",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Rediger destinasjon",
|
||||
"S3DestAddTitle": "Legg til S3 destinasjon",
|
||||
"S3DestEditDescription": "Oppdatere konfigurasjonen for denne S3-hendelsesstrømmingsdestinasjonen.",
|
||||
"S3DestAddDescription": "Konfigurer et nytt S3-endepunkt for å motta organisasjonens hendelser.",
|
||||
"S3DestAddDescription": "Konfigurer en ny Amazon S3 (eller S3-kompatibel) bucket for å motta din organisasjons hendelser.",
|
||||
"s3DestTabSettings": "Innstillinger",
|
||||
"s3DestTabFormat": "Format",
|
||||
"s3DestNameLabel": "Navn",
|
||||
"s3DestNamePlaceholder": "Min S3-destinasjon",
|
||||
"s3DestAccessKeyIdLabel": "AWS tilgangsnøkkel-ID",
|
||||
"s3DestSecretAccessKeyLabel": "AWS hemmelige tilgangsnøkkel",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Din AWS secret access key",
|
||||
"s3DestRegionLabel": "AWS-region",
|
||||
"s3DestBucketLabel": "Bucket-navn",
|
||||
"s3DestPrefixLabel": "Nøkkelprefiks (valgfritt)",
|
||||
"s3DestPrefixDescription": "Valgfritt bane-prefiks lagt til hver objektnøkkel. Objekter er lagret på {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Egendefinert endepunkt (valgfritt)",
|
||||
"s3DestEndpointDescription": "Overstyr S3-endepunktet for S3-kompatibel lagring som MinIO eller Cloudflare R2. La stå tomt for standard AWS S3.",
|
||||
"s3DestGzipLabel": "Gzip-komprimering",
|
||||
"s3DestGzipDescription": "Komprimer hvert opplastede objekt med gzip. Reduserer lagringskostnader og opplastingsstørrelse.",
|
||||
"s3DestFormatTitle": "Filformat",
|
||||
"s3DestFormatDescription": "Hvordan hendelser er serialisert inni hvert opplastede objekt.",
|
||||
"s3DestFormatJsonArrayDescription": "Hvert objekt er et JSON-array av hendelsesposter. Kompatibel med de fleste analyseverktøy.",
|
||||
"s3DestFormatNdjsonDescription": "Hvert objekt inneholder en JSON-post per linje (nylinje-delt JSON). Kompatibel med Athena, BigQuery, og Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Hvert objekt er en RFC-4180 CSV-fil med en overskriftsrad. Kolonnenavn er avledet fra hendelsesdatafeltene.",
|
||||
"s3DestSaveChanges": "Lagre endringer",
|
||||
"s3DestCreateDestination": "Opprett destinasjon",
|
||||
"s3DestUpdatedSuccess": "Destinasjon oppdatert vellykket",
|
||||
"s3DestCreatedSuccess": "Destinasjon opprettet vellykket",
|
||||
"s3DestUpdateFailed": "Kunne ikke oppdatere destinasjon",
|
||||
"s3DestCreateFailed": "Kunne ikke opprette destinasjon",
|
||||
"datadogDestEditTitle": "Rediger destinasjon",
|
||||
"datadogDestAddTitle": "Legg til Datadog destinasjon",
|
||||
"datadogDestEditDescription": "Oppdatere konfigurasjonen for denne Datadog-hendelsesstrømmingsdestinasjonen.",
|
||||
@@ -3174,7 +3219,7 @@
|
||||
"publicIpEndpoint": "Endepunkt",
|
||||
"lastTriggeredAt": "Siste utløste",
|
||||
"reject": "Avvis",
|
||||
"uptimeDaysAgo": "{count} days ago",
|
||||
"uptimeDaysAgo": "{count} dager siden",
|
||||
"uptimeToday": "I dag",
|
||||
"uptimeNoDataAvailable": "Ingen data tilgjengelig",
|
||||
"uptimeSuffix": "oppetid",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Fout opgetreden tijdens het verwijderen link",
|
||||
"shareDeleted": "Link verwijderd",
|
||||
"shareDeletedDescription": "De link is verwijderd",
|
||||
"shareDelete": "Verwijder Deel Link",
|
||||
"shareDeleteConfirm": "Bevestig verwijdering van Deel Link",
|
||||
"shareQuestionRemove": "Weet u zeker dat u deze deel link wilt verwijderen?",
|
||||
"shareMessageRemove": "Zodra verwijderd, zal de link niet meer werken en zal iedereen die het gebruikt de toegang tot de bron verliezen.",
|
||||
"shareTokenDescription": "De toegangstoken kan op twee manieren worden doorgegeven: als queryparameter of in de aanvraagheaders. Deze moeten worden doorgegeven van de client op elk verzoek voor geverifieerde toegang.",
|
||||
"accessToken": "Toegangs-token",
|
||||
"usageExamples": "Voorbeelden van gebruik",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "Eenmaal verwijderd, heeft deze gebruiker geen toegang meer tot de organisatie. Je kunt ze later altijd opnieuw uitnodigen, maar ze zullen de uitnodiging opnieuw moeten accepteren.",
|
||||
"userRemoveOrgConfirm": "Bevestig verwijderen gebruiker",
|
||||
"userRemoveOrg": "Gebruiker uit organisatie verwijderen",
|
||||
"userQuestionOrgRemoveSelf": "Weet u zeker dat u zichzelf uit deze organisatie wilt verwijderen?",
|
||||
"userMessageOrgRemoveSelf": "U verliest onmiddellijk toegang. Een beheerder kan u later opnieuw uitnodigen, maar u moet een nieuwe uitnodiging accepteren.",
|
||||
"userRemoveOrgConfirmSelf": "Bevestig Verwijder Mijn Persoon",
|
||||
"userRemoveOrgSelf": "Verwijder uzelf uit de organisatie",
|
||||
"userRemoveOrgSelfWarning": "U verliest onmiddellijk toegang tot deze organisatie.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "VERWIJDER MIJ UIT ORGANISATIE",
|
||||
"users": "Gebruikers",
|
||||
"accessRoleMember": "Lid",
|
||||
"accessRoleOwner": "Eigenaar",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Ongeldig e-mailadres",
|
||||
"inviteValidityDuration": "Selecteer een tijdsduur",
|
||||
"accessRoleSelectPlease": "Selecteer een rol",
|
||||
"removeOwnAdminRoleConfirmTitle": "Uw beheerderstoegang verwijderen?",
|
||||
"removeOwnAdminRoleConfirmDescription": "U zult na het opslaan geen beheerdersrechten meer hebben in deze organisatie. Een andere beheerder kan de toegang indien nodig herstellen.",
|
||||
"removeOwnAdminRoleConfirmButton": "Verwijder Mijn Beheerderstoegang",
|
||||
"removeOwnAdminRoleConfirmPhrase": "VERWIJDER MIJN BEHEERDERSTOEGANG",
|
||||
"ownerMustRetainAdminRole": "De organisatie-eigenaar moet minstens één beheerdersrol behouden.",
|
||||
"usernameRequired": "Gebruikersnaam is verplicht",
|
||||
"idpSelectPlease": "Selecteer een identiteitsprovider",
|
||||
"idpGenericOidc": "Algemene OAuth2/OIDC provider.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal de load balancering mogelijk maken.",
|
||||
"targetsSubmit": "Doelstellingen opslaan",
|
||||
"addTarget": "Doelwit toevoegen",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Round-robin routering werkt niet tussen locaties die niet met hetzelfde knooppunt zijn verbonden, maar failover werkt wel.",
|
||||
"targetErrorInvalidIp": "Ongeldig IP-adres",
|
||||
"targetErrorInvalidIpDescription": "Voer een geldig IP-adres of hostnaam in",
|
||||
"targetErrorInvalidPort": "Ongeldige poort",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Geldig wachtwoord",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "Bekijk",
|
||||
"configManaged": "Configuratie Beheerd",
|
||||
"connectedClient": "Verbonden Client",
|
||||
"resourceBlocked": "Bron geblokkeerd",
|
||||
"droppedByRule": "Achtergelaten door regel",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"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",
|
||||
"streamingLastSyncError": "Er is een fout opgetreden bij de laatste synchronisatie",
|
||||
"streamingUnexpectedError": "Er is een onverwachte fout opgetreden.",
|
||||
"streamingFailedToUpdate": "Bijwerken bestemming mislukt",
|
||||
"streamingDeletedSuccess": "Bestemming succesvol verwijderd",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Bestemming bewerken",
|
||||
"S3DestAddTitle": "S3-bestemming toevoegen",
|
||||
"S3DestEditDescription": "Werk de configuratie bij voor deze S3-gebeurtenisstreamingbestemming.",
|
||||
"S3DestAddDescription": "Configureer een nieuw S3-eindpunt om de gebeurtenissen van uw organisatie te ontvangen.",
|
||||
"S3DestAddDescription": "Configureer een nieuwe Amazon S3 (of S3-compatibele) bucket om de gebeurtenissen van uw organisatie te ontvangen.",
|
||||
"s3DestTabSettings": "Instellingen",
|
||||
"s3DestTabFormat": "Formaat",
|
||||
"s3DestNameLabel": "Naam",
|
||||
"s3DestNamePlaceholder": "Mijn S3-bestemming",
|
||||
"s3DestAccessKeyIdLabel": "AWS-toegangssleutel-ID",
|
||||
"s3DestSecretAccessKeyLabel": "AWS Geheime Toegangssleutel",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Uw AWS geheime toegangssleutel",
|
||||
"s3DestRegionLabel": "AWS-regio",
|
||||
"s3DestBucketLabel": "Bucketnaam",
|
||||
"s3DestPrefixLabel": "Sleutelvoorvoegsel (optioneel)",
|
||||
"s3DestPrefixDescription": "Optioneel padvoorvoegsel dat aan elke object sleutel wordt toegevoegd. Objecten worden opgeslagen op {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Aangepast Eindpunt (optioneel)",
|
||||
"s3DestEndpointDescription": "Overschrijf het S3-eindpunt voor S3-compatibele opslag zoals MinIO of Cloudflare R2. Laat leeg voor standaard AWS S3.",
|
||||
"s3DestGzipLabel": "Gzip-compressie",
|
||||
"s3DestGzipDescription": "Comprimeer elk geüpload object met gzip. Verlaagt opslagkosten en uploadgrootte.",
|
||||
"s3DestFormatTitle": "Bestandsformaat",
|
||||
"s3DestFormatDescription": "Hoe gebeurtenissen binnen elk geüpload object worden geserialiseerd.",
|
||||
"s3DestFormatJsonArrayDescription": "Elk object is een JSON-array van gebeurtenisrecords. Compatibel met de meeste analysetools.",
|
||||
"s3DestFormatNdjsonDescription": "Elk object bevat één JSON-record per regel (nieuwregel-gescheiden JSON). Compatibel met Athena, BigQuery en Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Elk object is een RFC-4180 CSV-bestand met een kopregel. Kolomnamen zijn afgeleid van de gebeurtenis gegevensvelden.",
|
||||
"s3DestSaveChanges": "Wijzigingen opslaan",
|
||||
"s3DestCreateDestination": "Bestemming maken",
|
||||
"s3DestUpdatedSuccess": "Bestemming succesvol bijgewerkt",
|
||||
"s3DestCreatedSuccess": "Bestemming succesvol gecreëerd",
|
||||
"s3DestUpdateFailed": "Bijwerken bestemming mislukt",
|
||||
"s3DestCreateFailed": "Aanmaken bestemming mislukt",
|
||||
"datadogDestEditTitle": "Bestemming bewerken",
|
||||
"datadogDestAddTitle": "Datadog-bestemming toevoegen",
|
||||
"datadogDestEditDescription": "Werk de configuratie bij voor deze Datadog-gebeurtenisstreamingbestemming.",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Wystąpił błąd podczas usuwania linku",
|
||||
"shareDeleted": "Link usunięty",
|
||||
"shareDeletedDescription": "Link został usunięty",
|
||||
"shareDelete": "Usuń link udostępniania",
|
||||
"shareDeleteConfirm": "Potwierdź usunięcie linku udostępniania",
|
||||
"shareQuestionRemove": "Czy na pewno chcesz usunąć ten link udostępniania?",
|
||||
"shareMessageRemove": "Po usunięciu, link przestanie działać i wszyscy korzystający z niego stracą dostęp do zasobu.",
|
||||
"shareTokenDescription": "Token dostępu może być przekazywany na dwa sposoby: jako parametr zapytania lub w nagłówkach żądania. Muszą być przekazywane z klienta na każde żądanie uwierzytelnionego dostępu.",
|
||||
"accessToken": "Token dostępu",
|
||||
"usageExamples": "Przykłady użycia",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "Po usunięciu ten użytkownik nie będzie miał już dostępu do organizacji. Zawsze możesz ponownie go zaprosić później, ale będzie musiał ponownie zaakceptować zaproszenie.",
|
||||
"userRemoveOrgConfirm": "Potwierdź usunięcie użytkownika",
|
||||
"userRemoveOrg": "Usuń użytkownika z organizacji",
|
||||
"userQuestionOrgRemoveSelf": "Czy na pewno chcesz usunąć się z tej organizacji?",
|
||||
"userMessageOrgRemoveSelf": "Stracisz dostęp natychmiastowo. Administrator może cię ponownie zaprosić, ale będziesz musiał przyjąć nowe zaproszenie.",
|
||||
"userRemoveOrgConfirmSelf": "Potwierdź usunięcie siebie",
|
||||
"userRemoveOrgSelf": "Usuń siebie z organizacji",
|
||||
"userRemoveOrgSelfWarning": "Natychmiast stracisz dostęp do tej organizacji.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "USUŃ SIEBIE Z ORGANIZACJI",
|
||||
"users": "Użytkownicy",
|
||||
"accessRoleMember": "Członek",
|
||||
"accessRoleOwner": "Właściciel",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Nieprawidłowy adres e-mail",
|
||||
"inviteValidityDuration": "Proszę wybrać okres ważności",
|
||||
"accessRoleSelectPlease": "Proszę wybrać rolę",
|
||||
"removeOwnAdminRoleConfirmTitle": "Usunąć dostęp administratora?",
|
||||
"removeOwnAdminRoleConfirmDescription": "Po zapisaniu nie będziesz już posiadał uprawnień administratora w tej organizacji. Inny administrator może przywrócić dostęp, jeśli to konieczne.",
|
||||
"removeOwnAdminRoleConfirmButton": "Usuń mój dostęp administratora",
|
||||
"removeOwnAdminRoleConfirmPhrase": "USUŃ MÓJ DOSTĘP ADMINISTRATORA",
|
||||
"ownerMustRetainAdminRole": "Właściciel organizacji musi zachować co najmniej jedną rolę administratora.",
|
||||
"usernameRequired": "Nazwa użytkownika jest wymagana",
|
||||
"idpSelectPlease": "Proszę wybrać dostawcę tożsamości",
|
||||
"idpGenericOidc": "Ogólny dostawca OAuth2/OIDC.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "Dodanie więcej niż jednego celu powyżej włączy równoważenie obciążenia.",
|
||||
"targetsSubmit": "Zapisz cele",
|
||||
"addTarget": "Dodaj cel",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Trasowanie round-robin nie będzie działać między witrynami, które nie są połączone z tym samym węzłem, ale przełączanie awaryjne będzie działać.",
|
||||
"targetErrorInvalidIp": "Nieprawidłowy adres IP",
|
||||
"targetErrorInvalidIpDescription": "Wprowadź prawidłowy adres IP lub nazwę hosta",
|
||||
"targetErrorInvalidPort": "Nieprawidłowy port",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Prawidłowe hasło",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "Zobacz",
|
||||
"configManaged": "Konfiguracja zarządzana",
|
||||
"connectedClient": "Połączony Klient",
|
||||
"resourceBlocked": "Zasób zablokowany",
|
||||
"droppedByRule": "Upuszczone przez regułę",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"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",
|
||||
"streamingLastSyncError": "Wystąpił błąd podczas ostatniej synchronizacji",
|
||||
"streamingUnexpectedError": "Wystąpił nieoczekiwany błąd.",
|
||||
"streamingFailedToUpdate": "Nie udało się zaktualizować miejsca docelowego",
|
||||
"streamingDeletedSuccess": "Cel usunięty pomyślnie",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Edytuj Miejsce Docelowe",
|
||||
"S3DestAddTitle": "Dodaj Miejsce Docelowe S3",
|
||||
"S3DestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń S3.",
|
||||
"S3DestAddDescription": "Skonfiguruj nowy punkt końcowy S3, aby odbierać zdarzenia Twojej organizacji.",
|
||||
"S3DestAddDescription": "Skonfiguruj nowy zasobnik Amazon S3 (lub zgodny z S3), aby otrzymywać zdarzenia twojej organizacji.",
|
||||
"s3DestTabSettings": "Ustawienia",
|
||||
"s3DestTabFormat": "Format",
|
||||
"s3DestNameLabel": "Nazwa",
|
||||
"s3DestNamePlaceholder": "Moje miejsce docelowe S3",
|
||||
"s3DestAccessKeyIdLabel": "AWS Access Key ID",
|
||||
"s3DestSecretAccessKeyLabel": "AWS Secret Access Key",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Twój AWS Secret Access Key",
|
||||
"s3DestRegionLabel": "Region AWS",
|
||||
"s3DestBucketLabel": "Nazwa kubła",
|
||||
"s3DestPrefixLabel": "Prefiks klucza (opcjonalnie)",
|
||||
"s3DestPrefixDescription": "Opcjonalny prefiks ścieżki dołączony do każdego klucza obiektu. Obiekty są przechowywane w {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Niestandardowy punkt końcowy (opcjonalnie)",
|
||||
"s3DestEndpointDescription": "Nadpisz punkt końcowy S3 dla zgodnego przechowywania danych, takiego jak MinIO lub Cloudflare R2. Pozostaw puste dla standardowego AWS S3.",
|
||||
"s3DestGzipLabel": "Kompresja Gzip",
|
||||
"s3DestGzipDescription": "Skompresuj każdy przesłany obiekt za pomocą gzip. Zmniejsza koszty przechowywania i rozmiar przesyłu.",
|
||||
"s3DestFormatTitle": "Format pliku",
|
||||
"s3DestFormatDescription": "Jak zdarzenia są serializowane w każdym przesłanym obiekcie.",
|
||||
"s3DestFormatJsonArrayDescription": "Każdy obiekt to tablica JSON z rekordami zdarzeń. Zgodne z większością narzędzi analitycznych.",
|
||||
"s3DestFormatNdjsonDescription": "Każdy obiekt zawiera jeden rekord JSON na linię (nowa linia-dzielone JSON). Zgodne z Athena, BigQuery i Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Każdy obiekt to plik CSV zgodny z RFC-4180 z wierszem nagłówka. Nazwy kolumn pochodzą z pól danych zdarzeń.",
|
||||
"s3DestSaveChanges": "Zapisz zmiany",
|
||||
"s3DestCreateDestination": "Utwórz miejsce docelowe",
|
||||
"s3DestUpdatedSuccess": "Miejsce docelowe zaktualizowane pomyślnie",
|
||||
"s3DestCreatedSuccess": "Miejsce docelowe utworzone pomyślnie",
|
||||
"s3DestUpdateFailed": "Nie udało się zaktualizować miejsca docelowego",
|
||||
"s3DestCreateFailed": "Nie udało się utworzyć miejsca docelowego",
|
||||
"datadogDestEditTitle": "Edytuj Miejsce Docelowe",
|
||||
"datadogDestAddTitle": "Dodaj Miejsce Docelowe Datadog",
|
||||
"datadogDestEditDescription": "Zaktualizuj konfigurację dla tego miejsca docelowego strumieniowego zdarzeń Datadog.",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Ocorreu um erro ao apagar o link",
|
||||
"shareDeleted": "Link excluído",
|
||||
"shareDeletedDescription": "O link foi eliminado",
|
||||
"shareDelete": "Excluir Link de Compartilhamento",
|
||||
"shareDeleteConfirm": "Confirmar Exclusão de Link de Compartilhamento",
|
||||
"shareQuestionRemove": "Tem certeza de que deseja excluir este link de compartilhamento?",
|
||||
"shareMessageRemove": "Uma vez excluído, o link não funcionará mais e qualquer pessoa que o utilizar perderá o acesso ao recurso.",
|
||||
"shareTokenDescription": "O token de acesso pode ser passado de duas maneiras: como um parâmetro de consulta ou nos cabeçalhos da solicitação. Estes devem ser passados do cliente em todas as solicitações para acesso autenticado.",
|
||||
"accessToken": "Token de acesso",
|
||||
"usageExamples": "Exemplos de uso",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "Uma vez removido, este utilizador não terá mais acesso à organização. Você sempre pode reconvidá-lo depois, mas eles precisarão aceitar o convite novamente.",
|
||||
"userRemoveOrgConfirm": "Confirmar Remoção do Usuário",
|
||||
"userRemoveOrg": "Remover Usuário da Organização",
|
||||
"userQuestionOrgRemoveSelf": "Tem certeza de que deseja se remover desta organização?",
|
||||
"userMessageOrgRemoveSelf": "Você perderá o acesso imediatamente. Um administrador poderá convidá-lo novamente mais tarde, mas você precisará aceitar um novo convite.",
|
||||
"userRemoveOrgConfirmSelf": "Confirmar a Remoção de Mim Mesmo",
|
||||
"userRemoveOrgSelf": "Remover-se da organização",
|
||||
"userRemoveOrgSelfWarning": "Você perderá o acesso a esta organização imediatamente.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "REMOVER-ME DA ORG",
|
||||
"users": "Utilizadores",
|
||||
"accessRoleMember": "Membro",
|
||||
"accessRoleOwner": "Proprietário",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Endereço de email inválido",
|
||||
"inviteValidityDuration": "Por favor, selecione uma duração",
|
||||
"accessRoleSelectPlease": "Por favor, selecione uma função",
|
||||
"removeOwnAdminRoleConfirmTitle": "Remover seu acesso de administrador?",
|
||||
"removeOwnAdminRoleConfirmDescription": "Você não terá mais permissões de administrador nesta organização após salvar. Outro administrador pode restaurar seu acesso, se necessário.",
|
||||
"removeOwnAdminRoleConfirmButton": "Remover Meu Acesso de Administrador",
|
||||
"removeOwnAdminRoleConfirmPhrase": "REMOVER MEU ACESSO DE ADMIN",
|
||||
"ownerMustRetainAdminRole": "O proprietário da organização deve manter pelo menos um papel de administrador.",
|
||||
"usernameRequired": "Nome de utilizador é obrigatório",
|
||||
"idpSelectPlease": "Por favor, selecione um provedor de identidade",
|
||||
"idpGenericOidc": "Provedor genérico OAuth2/OIDC.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "Adicionar mais de um alvo acima habilitará o balanceamento de carga.",
|
||||
"targetsSubmit": "Guardar Alvos",
|
||||
"addTarget": "Adicionar Alvo",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "O roteamento round robin não funcionará entre sites que não estão conectados ao mesmo nó, mas o failover funcionará.",
|
||||
"targetErrorInvalidIp": "Endereço IP inválido",
|
||||
"targetErrorInvalidIpDescription": "Por favor, insira um endereço IP ou nome de host válido",
|
||||
"targetErrorInvalidPort": "Porta inválida",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Senha válida",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "Visualizar",
|
||||
"configManaged": "Configuração Gerenciada",
|
||||
"connectedClient": "Cliente Conectado",
|
||||
"resourceBlocked": "Recurso bloqueado",
|
||||
"droppedByRule": "Derrubado pela regra",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"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",
|
||||
"streamingLastSyncError": "Ocorreu um erro na última sincronização",
|
||||
"streamingUnexpectedError": "Ocorreu um erro inesperado.",
|
||||
"streamingFailedToUpdate": "Falha ao atualizar destino",
|
||||
"streamingDeletedSuccess": "Destino apagado com sucesso",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Editar Destino",
|
||||
"S3DestAddTitle": "Adicionar Destino S3",
|
||||
"S3DestEditDescription": "Atualize a configuração para este destino de streaming de eventos S3.",
|
||||
"S3DestAddDescription": "Configure um novo endpoint S3 para receber os eventos da sua organização.",
|
||||
"S3DestAddDescription": "Configure um novo bucket Amazon S3 (ou compatível com S3) para receber os eventos da sua organização.",
|
||||
"s3DestTabSettings": "Configurações",
|
||||
"s3DestTabFormat": "Formato",
|
||||
"s3DestNameLabel": "Nome",
|
||||
"s3DestNamePlaceholder": "Meu destino S3",
|
||||
"s3DestAccessKeyIdLabel": "ID da Chave de Acesso AWS",
|
||||
"s3DestSecretAccessKeyLabel": "Chave de Acesso Secreta AWS",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Sua chave de acesso secreta AWS",
|
||||
"s3DestRegionLabel": "Região AWS",
|
||||
"s3DestBucketLabel": "Nome do Bucket",
|
||||
"s3DestPrefixLabel": "Prefixo da Chave (opcional)",
|
||||
"s3DestPrefixDescription": "Prefixo de caminho opcional adicionado a cada chave de objeto. Os objetos são armazenados em {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Endpoint Personalizado (opcional)",
|
||||
"s3DestEndpointDescription": "Substitua o endpoint S3 por armazenamento compatível com S3, como MinIO ou Cloudflare R2. Deixe em branco para o padrão AWS S3.",
|
||||
"s3DestGzipLabel": "Compressão Gzip",
|
||||
"s3DestGzipDescription": "Comprime cada objeto carregado com gzip. Reduz custos de armazenamento e tamanho de upload.",
|
||||
"s3DestFormatTitle": "Formato de Arquivo",
|
||||
"s3DestFormatDescription": "Como os eventos são serializados dentro de cada objeto carregado.",
|
||||
"s3DestFormatJsonArrayDescription": "Cada objeto é um array JSON de registros de eventos. Compatível com a maioria das ferramentas de análise.",
|
||||
"s3DestFormatNdjsonDescription": "Cada objeto contém um registro JSON por linha (JSON delimitado por nova linha). Compatível com Athena, BigQuery e Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Cada objeto é um arquivo CSV RFC-4180 com uma linha de cabeçalho. Nomes de colunas são derivados dos campos de dados do evento.",
|
||||
"s3DestSaveChanges": "Salvar Alterações",
|
||||
"s3DestCreateDestination": "Criar Destino",
|
||||
"s3DestUpdatedSuccess": "Destino atualizado com sucesso",
|
||||
"s3DestCreatedSuccess": "Destino criado com sucesso",
|
||||
"s3DestUpdateFailed": "Falha ao atualizar destino",
|
||||
"s3DestCreateFailed": "Falha ao criar destino",
|
||||
"datadogDestEditTitle": "Editar Destino",
|
||||
"datadogDestAddTitle": "Adicionar Destino Datadog",
|
||||
"datadogDestEditDescription": "Atualize a configuração para este destino de streaming de eventos Datadog.",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Произошла ошибка при удалении ссылки",
|
||||
"shareDeleted": "Ссылка удалена",
|
||||
"shareDeletedDescription": "Ссылка была успешно удалена",
|
||||
"shareDelete": "Удалить общую ссылку",
|
||||
"shareDeleteConfirm": "Подтвердите удаление общей ссылки",
|
||||
"shareQuestionRemove": "Вы уверены, что хотите удалить эту общую ссылку?",
|
||||
"shareMessageRemove": "После удаления ссылка перестанет работать, и все, кто ее использует, потеряют доступ к ресурсу.",
|
||||
"shareTokenDescription": "Токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Они должны быть переданы от клиента по каждому запросу для аутентифицированного доступа.",
|
||||
"accessToken": "Токен доступа",
|
||||
"usageExamples": "Примеры использования",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "После удаления этот пользователь больше не будет иметь доступ к организации. Вы всегда можете пригласить его заново, но ему нужно будет снова принять приглашение.",
|
||||
"userRemoveOrgConfirm": "Подтвердить удаление пользователя",
|
||||
"userRemoveOrg": "Удалить пользователя из организации",
|
||||
"userQuestionOrgRemoveSelf": "Вы уверены, что хотите удалить себя из этой организации?",
|
||||
"userMessageOrgRemoveSelf": "Вы немедленно потеряете доступ. Администратор сможет снова пригласить вас позже, но вам нужно будет принять новое приглашение.",
|
||||
"userRemoveOrgConfirmSelf": "Подтвердите удаление себя",
|
||||
"userRemoveOrgSelf": "Удалите себя из организации",
|
||||
"userRemoveOrgSelfWarning": "Вы немедленно потеряете доступ к этой организации.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "Удалить себя из организации",
|
||||
"users": "Пользователи",
|
||||
"accessRoleMember": "Участник",
|
||||
"accessRoleOwner": "Владелец",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Неверный адрес Email",
|
||||
"inviteValidityDuration": "Пожалуйста, выберите продолжительность",
|
||||
"accessRoleSelectPlease": "Пожалуйста, выберите роль",
|
||||
"removeOwnAdminRoleConfirmTitle": "Удалить доступ администратора?",
|
||||
"removeOwnAdminRoleConfirmDescription": "После сохранения у вас больше не будет прав администратора в этой организации. Другой администратор может восстановить доступ, если это необходимо.",
|
||||
"removeOwnAdminRoleConfirmButton": "Удалить мой доступ администратора",
|
||||
"removeOwnAdminRoleConfirmPhrase": "УДАЛИТЬ МОЙ ДОСТУП АДМИНИСТРАТОРА",
|
||||
"ownerMustRetainAdminRole": "Владелец организации должен сохранить по крайней мере одну роль администратора.",
|
||||
"usernameRequired": "Имя пользователя обязательно",
|
||||
"idpSelectPlease": "Пожалуйста, выберите Identity Provider",
|
||||
"idpGenericOidc": "Обычный OAuth2/OIDC provider.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "Добавление более одной цели выше включит балансировку нагрузки.",
|
||||
"targetsSubmit": "Сохранить цели",
|
||||
"addTarget": "Добавить цель",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Роутинг с балансировкой нагрузки не будет работать между сайтами, не подключенными к одному и тому же узлу, но подмена будет работать.",
|
||||
"targetErrorInvalidIp": "Неверный IP-адрес",
|
||||
"targetErrorInvalidIpDescription": "Пожалуйста, введите действительный IP адрес или имя хоста",
|
||||
"targetErrorInvalidPort": "Неверный порт",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Допустимый пароль",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "Просмотр",
|
||||
"configManaged": "Конфигурация управляется",
|
||||
"connectedClient": "Подключенный клиент",
|
||||
"resourceBlocked": "Ресурс заблокирован",
|
||||
"droppedByRule": "Отброшено по правилам",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"streamingDatadogTitle": "Datadog",
|
||||
"streamingDatadogDescription": "Перенаправлять события непосредственно на ваш аккаунт в Datadog. Скоро будет доступно.",
|
||||
"streamingTypePickerDescription": "Выберите тип назначения, чтобы начать.",
|
||||
"streamingFailedToLoad": "Не удалось загрузить места назначения",
|
||||
"streamingLastSyncError": "Во время последней синхронизации произошла ошибка",
|
||||
"streamingUnexpectedError": "Произошла непредвиденная ошибка.",
|
||||
"streamingFailedToUpdate": "Не удалось обновить место назначения",
|
||||
"streamingDeletedSuccess": "Адрес назначения успешно удален",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Редактировать пункт назначения",
|
||||
"S3DestAddTitle": "Добавить S3 пункт назначения",
|
||||
"S3DestEditDescription": "Обновите конфигурацию для этого S3 пункта назначения потоковых событий.",
|
||||
"S3DestAddDescription": "Настройте новую S3 конечную точку для получения событий вашей организации.",
|
||||
"S3DestAddDescription": "Настройте новый Amazon S3 (или совместимое S3) хранилище для получения событий вашей организации.",
|
||||
"s3DestTabSettings": "Настройки",
|
||||
"s3DestTabFormat": "Формат",
|
||||
"s3DestNameLabel": "Имя",
|
||||
"s3DestNamePlaceholder": "Моя S3 конечная точка",
|
||||
"s3DestAccessKeyIdLabel": "Идентификатор ключа доступа AWS",
|
||||
"s3DestSecretAccessKeyLabel": "Секретный ключ доступа AWS",
|
||||
"s3DestSecretAccessKeyPlaceholder": "Ваш секретный ключ доступа AWS",
|
||||
"s3DestRegionLabel": "Регион AWS",
|
||||
"s3DestBucketLabel": "Имя хранилища",
|
||||
"s3DestPrefixLabel": "Префикс ключа (по желанию)",
|
||||
"s3DestPrefixDescription": "Необязательный префикс пути, добавляется к каждому ключу объекта. Объекты хранятся в {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}.",
|
||||
"s3DestEndpointLabel": "Пользовательская конечная точка (по желанию)",
|
||||
"s3DestEndpointDescription": "Переопределите конечную точку S3 для совместимого хранилища, такого как MinIO или Cloudflare R2. Оставьте пустым для стандартного AWS S3.",
|
||||
"s3DestGzipLabel": "Сжатие Gzip",
|
||||
"s3DestGzipDescription": "Сжимайте каждый загруженный объект с помощью gzip. Уменьшает стоимость хранения и размер загрузки.",
|
||||
"s3DestFormatTitle": "Формат файла",
|
||||
"s3DestFormatDescription": "Как события сериализуются внутри каждого загруженного объекта.",
|
||||
"s3DestFormatJsonArrayDescription": "Каждый объект — это JSON массив записей событий. Совместим с большинством аналитических инструментов.",
|
||||
"s3DestFormatNdjsonDescription": "Каждый объект содержит одну запись JSON на строку (JSON, разделённый новой строкой). Совместим с Athena, BigQuery и Spark.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Каждый объект представляет собой CSV файл по стандарту RFC-4180 с заголовочной строкой. Имена столбцов выведены из полей данных событий.",
|
||||
"s3DestSaveChanges": "Сохранить изменения",
|
||||
"s3DestCreateDestination": "Создать конечную точку",
|
||||
"s3DestUpdatedSuccess": "Конечная точка успешно обновлена",
|
||||
"s3DestCreatedSuccess": "Конечная точка успешно создана",
|
||||
"s3DestUpdateFailed": "Не удалось обновить конечную точку",
|
||||
"s3DestCreateFailed": "Не удалось создать конечную точку",
|
||||
"datadogDestEditTitle": "Редактировать пункт назначения",
|
||||
"datadogDestAddTitle": "Добавить пункт назначения Datadog",
|
||||
"datadogDestEditDescription": "Обновите конфигурацию для этого пункта назначения потоковых событий Datadog.",
|
||||
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "Bağlantı silinirken bir hata oluştu",
|
||||
"shareDeleted": "Bağlantı silindi",
|
||||
"shareDeletedDescription": "Bağlantı silindi",
|
||||
"shareDelete": "Paylaşım Bağlantısını Sil",
|
||||
"shareDeleteConfirm": "Paylaşım Bağlantısının Silinmesini Onayla",
|
||||
"shareQuestionRemove": "Bu paylaşım bağlantısını silmek istediğinizden emin misiniz?",
|
||||
"shareMessageRemove": "Silindikten sonra, bağlantı artık çalışmayacak ve kullanan herkes kaynağa erişimini kaybedecek.",
|
||||
"shareTokenDescription": "Erişim jetonunuz iki şekilde iletilebilir: sorgu parametresi olarak veya istek başlıklarında. Kimlik doğrulanmış erişim için her istekten müşteri tarafından iletilmelidir.",
|
||||
"accessToken": "Erişim Jetonu",
|
||||
"usageExamples": "Kullanım Örnekleri",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "Kaldırıldığında, bu kullanıcı organizasyona artık erişim sağlayamayacak. Kullanıcı tekrar davet edilebilir, ancak daveti kabul etmesi gerekecek.",
|
||||
"userRemoveOrgConfirm": "Kullanıcıyı Kaldırmayı Onayla",
|
||||
"userRemoveOrg": "Kullanıcıyı Organizasyondan Kaldır",
|
||||
"userQuestionOrgRemoveSelf": "Bu organizasyondan kendinizi kaldırmak istediğinizden emin misiniz?",
|
||||
"userMessageOrgRemoveSelf": "Erişiminizi hemen kaybedeceksiniz. Bir yönetici daha sonra sizi tekrar davet edebilir, ancak yeni bir daveti kabul etmeniz gerekecek.",
|
||||
"userRemoveOrgConfirmSelf": "Kendimi Kaldırmayı Onayla",
|
||||
"userRemoveOrgSelf": "Kendinizi organizasyondan kaldırın",
|
||||
"userRemoveOrgSelfWarning": "Bu organizasyona erişiminizi anında kaybedeceksiniz.",
|
||||
"userRemoveOrgConfirmPhraseSelf": "KENDİMİ ORGANİZASYONDAN KALDIR",
|
||||
"users": "Kullanıcılar",
|
||||
"accessRoleMember": "Üye",
|
||||
"accessRoleOwner": "Sahip",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "Geçersiz e-posta adresi",
|
||||
"inviteValidityDuration": "Lütfen bir süre seçin",
|
||||
"accessRoleSelectPlease": "Lütfen bir rol seçin",
|
||||
"removeOwnAdminRoleConfirmTitle": "Yönetici erişiminizi kaldırmak istiyor musunuz?",
|
||||
"removeOwnAdminRoleConfirmDescription": "Kaydettikten sonra, bu organizasyonda artık yönetici izinleriniz olmayacak. Gerekirse başka bir yönetici erişimi geri yükleyebilir.",
|
||||
"removeOwnAdminRoleConfirmButton": "Yönetici Erişimi Kaldır",
|
||||
"removeOwnAdminRoleConfirmPhrase": "YÖNETİCİ ERİŞİMİMİ KALDIR",
|
||||
"ownerMustRetainAdminRole": "Organizasyon sahibi en az bir yönetici rolü bulundurmalıdır.",
|
||||
"usernameRequired": "Kullanıcı adı gereklidir",
|
||||
"idpSelectPlease": "Lütfen bir kimlik sağlayıcı seçin",
|
||||
"idpGenericOidc": "Genel OAuth2/OIDC sağlayıcısı.",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "Yukarıdaki birden fazla hedef ekleyerek yük dengeleme etkinleştirilecektir.",
|
||||
"targetsSubmit": "Hedefleri Kaydet",
|
||||
"addTarget": "Hedef Ekle",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Round robin yönlendirme, aynı düğüme bağlı olmayan siteler arasında çalışmayacaktır, ancak failover çalışacaktır.",
|
||||
"targetErrorInvalidIp": "Geçersiz IP adresi",
|
||||
"targetErrorInvalidIpDescription": "Lütfen geçerli bir IP adresi veya host adı girin",
|
||||
"targetErrorInvalidPort": "Geçersiz port",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "Geçerli Şifre",
|
||||
"validEmail": "Geçerli E-posta",
|
||||
"validSSO": "Geçerli SSO",
|
||||
"view": "Görüntüle",
|
||||
"configManaged": "Yapılandırma Yönetildi",
|
||||
"connectedClient": "Bağlı İstemci",
|
||||
"resourceBlocked": "Kaynak Engellendi",
|
||||
"droppedByRule": "Kurallara Göre Çıkartıldı",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"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",
|
||||
"streamingLastSyncError": "Son senkronizasyonda bir hata oluştu",
|
||||
"streamingUnexpectedError": "Beklenmeyen bir hata oluştu.",
|
||||
"streamingFailedToUpdate": "Hedef güncellenemedi",
|
||||
"streamingDeletedSuccess": "Hedef başarıyla silindi",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "Hedefi Düzenle",
|
||||
"S3DestAddTitle": "S3 Hedefi Ekle",
|
||||
"S3DestEditDescription": "Bu S3 olay akışı hedefi için yapılandırmayı güncelleyin.",
|
||||
"S3DestAddDescription": "Kuruluşunuzun olaylarını almak için yeni bir S3 uç noktası yapılandırın.",
|
||||
"S3DestAddDescription": "Kuruluşunuzun etkinliklerini almak için yeni bir Amazon S3 (veya S3-uyumlu) kovası yapılandırın.",
|
||||
"s3DestTabSettings": "Ayarlar",
|
||||
"s3DestTabFormat": "Biçim",
|
||||
"s3DestNameLabel": "Ad",
|
||||
"s3DestNamePlaceholder": "Benim S3 hedefim",
|
||||
"s3DestAccessKeyIdLabel": "AWS Erişim Anahtar Kimliği",
|
||||
"s3DestSecretAccessKeyLabel": "AWS Gizli Erişim Anahtarı",
|
||||
"s3DestSecretAccessKeyPlaceholder": "AWS gizli erişim anahtarınız",
|
||||
"s3DestRegionLabel": "AWS Bölgesi",
|
||||
"s3DestBucketLabel": "Kova Adı",
|
||||
"s3DestPrefixLabel": "Anahtar Ön Eki (isteğe bağlı)",
|
||||
"s3DestPrefixDescription": "Her nesne anahtarının önüne eklenen isteğe bağlı yol öneki. Nesneler {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename} konumunda saklanır.",
|
||||
"s3DestEndpointLabel": "Özel Uç Nokta (isteğe bağlı)",
|
||||
"s3DestEndpointDescription": "MinIO veya Cloudflare R2 gibi S3-uyumlu depolama için S3 uç noktasını geçersiz kılın. Standart AWS S3 için boş bırakın.",
|
||||
"s3DestGzipLabel": "Gzip sıkıştırması",
|
||||
"s3DestGzipDescription": "Her yüklü nesneyi gzip ile sıkıştırın. Depolama maliyetlerini ve yükleme boyutunu azaltır.",
|
||||
"s3DestFormatTitle": "Dosya Biçimi",
|
||||
"s3DestFormatDescription": "Etkinliklerin her yüklendiği nesne içinde nasıl serileştirildiği.",
|
||||
"s3DestFormatJsonArrayDescription": "Her nesne bir olay kayıtlarının JSON dizisidir. Çoğu analiz aracıyla uyumludur.",
|
||||
"s3DestFormatNdjsonDescription": "Her nesne satır başına bir JSON kaydı içerir (yeni satır ile ayrılmış JSON). Athena, BigQuery ve Spark ile uyumludur.",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "Her nesne, bir başlık satırı ile birlikte RFC-4180 CSV dosyasıdır. Sütun isimleri olay verileri alanlarından türetilmiştir.",
|
||||
"s3DestSaveChanges": "Değişiklikleri Kaydet",
|
||||
"s3DestCreateDestination": "Hedef Oluştur",
|
||||
"s3DestUpdatedSuccess": "Hedef başarıyla güncellendi",
|
||||
"s3DestCreatedSuccess": "Hedef başarıyla oluşturuldu",
|
||||
"s3DestUpdateFailed": "Hedef güncellenemedi",
|
||||
"s3DestCreateFailed": "Hedef oluşturulamadı",
|
||||
"datadogDestEditTitle": "Hedefi Düzenle",
|
||||
"datadogDestAddTitle": "Datadog Hedefi Ekle",
|
||||
"datadogDestEditDescription": "Bu Datadog olay akışı hedefi için yapılandırmayı güncelleyin.",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"trialActive": "免费试用中",
|
||||
"trialExpired": "试用到期",
|
||||
"trialHasEnded": "您的试用已结束。",
|
||||
"trialDaysRemaining": "{count, plural, one {# day remaining} other {# days remaining}}",
|
||||
"trialDaysRemaining": "{count, plural, other {# 天剩余}}",
|
||||
"trialDaysLeftShort": "试用期剩余 {days} 天",
|
||||
"trialGoToBilling": "转到账单页面",
|
||||
"subscriptionViolationViewBilling": "查看计费",
|
||||
@@ -156,6 +156,10 @@
|
||||
"shareErrorDeleteMessage": "删除链接时出错",
|
||||
"shareDeleted": "链接已删除",
|
||||
"shareDeletedDescription": "链接已删除",
|
||||
"shareDelete": "删除共享链接",
|
||||
"shareDeleteConfirm": "确认删除共享链接",
|
||||
"shareQuestionRemove": "您确定要删除这个共享链接吗?",
|
||||
"shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。",
|
||||
"shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。",
|
||||
"accessToken": "访问令牌",
|
||||
"usageExamples": "用法示例",
|
||||
@@ -303,7 +307,7 @@
|
||||
"accessUserManage": "管理用户",
|
||||
"accessUsersDescription": "邀请和管理访问此组织的用户",
|
||||
"accessUsersSearch": "搜索用户...",
|
||||
"accessUsersRoleFilterCount": "{count, plural, one {# role} other {# roles}}",
|
||||
"accessUsersRoleFilterCount": "{count, plural, other {# 角色}}",
|
||||
"accessUsersRoleFilterClear": "清除角色过滤器",
|
||||
"accessUserCreate": "创建用户",
|
||||
"accessUserRemove": "删除用户",
|
||||
@@ -523,6 +527,12 @@
|
||||
"userMessageOrgRemove": "一旦删除,这个用户将不再能够访问组织。 你总是可以稍后重新邀请他们,但他们需要再次接受邀请。",
|
||||
"userRemoveOrgConfirm": "确认删除用户",
|
||||
"userRemoveOrg": "从组织中删除用户",
|
||||
"userQuestionOrgRemoveSelf": "你确定要将自己从这个组织中移除吗?",
|
||||
"userMessageOrgRemoveSelf": "你将立即失去访问权限。管理员稍后可以再次邀请你,但你需要接受新的邀请。",
|
||||
"userRemoveOrgConfirmSelf": "确认删除我自己",
|
||||
"userRemoveOrgSelf": "将自己从组织中移除",
|
||||
"userRemoveOrgSelfWarning": "你将立即失去对此组织的访问权限。",
|
||||
"userRemoveOrgConfirmPhraseSelf": "从组织中移除我自己",
|
||||
"users": "用户",
|
||||
"accessRoleMember": "成员",
|
||||
"accessRoleOwner": "所有者",
|
||||
@@ -531,6 +541,11 @@
|
||||
"emailInvalid": "无效的电子邮件地址",
|
||||
"inviteValidityDuration": "请选择持续时间",
|
||||
"accessRoleSelectPlease": "请选择一个角色",
|
||||
"removeOwnAdminRoleConfirmTitle": "移除你的管理员权限?",
|
||||
"removeOwnAdminRoleConfirmDescription": "保存后,你将不再拥有该组织的管理员权限。如果需要,其他管理员可以恢复访问。",
|
||||
"removeOwnAdminRoleConfirmButton": "移除我的管理员访问权限",
|
||||
"removeOwnAdminRoleConfirmPhrase": "移除我的管理员访问",
|
||||
"ownerMustRetainAdminRole": "组织所有者必须保留至少一个管理员角色。",
|
||||
"usernameRequired": "必须输入用户名",
|
||||
"idpSelectPlease": "请选择身份提供商",
|
||||
"idpGenericOidc": "通用的 OAuth2/OIDC 提供商。",
|
||||
@@ -658,6 +673,7 @@
|
||||
"targetNoOneDescription": "在上面添加多个目标将启用负载平衡。",
|
||||
"targetsSubmit": "保存目标",
|
||||
"addTarget": "添加目标",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "轮询路由在未连接到相同节点的站点之间将不起作用,但故障转移会生效。",
|
||||
"targetErrorInvalidIp": "无效的 IP 地址",
|
||||
"targetErrorInvalidIpDescription": "请输入有效的IP地址或主机名",
|
||||
"targetErrorInvalidPort": "无效的端口",
|
||||
@@ -1499,7 +1515,7 @@
|
||||
"alertingGraphCanvasTitle": "规则流程",
|
||||
"alertingGraphCanvasDescription": "源、触发器和操作的视觉概况。选择一个节点,在面板上进行编辑。",
|
||||
"alertingNodeNotConfigured": "尚未配置",
|
||||
"alertingNodeActionsCount": "{count, plural, one {# action} other {# actions}}",
|
||||
"alertingNodeActionsCount": "{count, plural, other {# 操作}}",
|
||||
"alertingNodeRoleSource": "来源",
|
||||
"alertingNodeRoleTrigger": "触发",
|
||||
"alertingNodeRoleAction": "行为",
|
||||
@@ -2051,7 +2067,7 @@
|
||||
"createInternalResourceDialogName": "名称",
|
||||
"createInternalResourceDialogSite": "站点",
|
||||
"selectSite": "选择站点...",
|
||||
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
|
||||
"multiSitesSelectorSitesCount": "{count, plural, other {# 个网站}}",
|
||||
"noSitesFound": "未找到站点。",
|
||||
"createInternalResourceDialogProtocol": "协议",
|
||||
"createInternalResourceDialogTcp": "TCP",
|
||||
@@ -2652,6 +2668,8 @@
|
||||
"validPassword": "有效密码",
|
||||
"validEmail": "Valid email",
|
||||
"validSSO": "Valid SSO",
|
||||
"view": "查看",
|
||||
"configManaged": "配置已管理",
|
||||
"connectedClient": "已连接客户端",
|
||||
"resourceBlocked": "资源被阻止",
|
||||
"droppedByRule": "被规则删除",
|
||||
@@ -3062,7 +3080,7 @@
|
||||
"streamingDatadogTitle": "Datadog",
|
||||
"streamingDatadogDescription": "直接转发事件到您的Datadog 帐户。即将推出。",
|
||||
"streamingTypePickerDescription": "选择要开始的目标类型。",
|
||||
"streamingFailedToLoad": "加载目的地失败",
|
||||
"streamingLastSyncError": "最后一次同步时发生错误",
|
||||
"streamingUnexpectedError": "发生意外错误.",
|
||||
"streamingFailedToUpdate": "更新目标失败",
|
||||
"streamingDeletedSuccess": "目标删除成功",
|
||||
@@ -3079,7 +3097,34 @@
|
||||
"S3DestEditTitle": "编辑目的地",
|
||||
"S3DestAddTitle": "添加 S3 目的地",
|
||||
"S3DestEditDescription": "更新此 S3 事件流目的地的配置。",
|
||||
"S3DestAddDescription": "配置新的 S3 终端以接收您的组织事件。",
|
||||
"S3DestAddDescription": "配置一个新的 Amazon S3(或兼容 S3 的)存储桶以接收您的组织事件。",
|
||||
"s3DestTabSettings": "设置",
|
||||
"s3DestTabFormat": "格式",
|
||||
"s3DestNameLabel": "名称",
|
||||
"s3DestNamePlaceholder": "我的 S3 目的地",
|
||||
"s3DestAccessKeyIdLabel": "AWS 访问密钥 ID",
|
||||
"s3DestSecretAccessKeyLabel": "AWS 秘密访问密钥",
|
||||
"s3DestSecretAccessKeyPlaceholder": "您的 AWS 密钥",
|
||||
"s3DestRegionLabel": "AWS 地区",
|
||||
"s3DestBucketLabel": "存储桶名称",
|
||||
"s3DestPrefixLabel": "密钥前缀(可选)",
|
||||
"s3DestPrefixDescription": "每个对象密钥前加的可选路径前缀。对象存储在 {prefix}/{logType}/{YYYY}/{MM}/{DD}/{filename}。",
|
||||
"s3DestEndpointLabel": "自定义端点(可选)",
|
||||
"s3DestEndpointDescription": "替代 S3 端点用于 MinIO 或 Cloudflare R2 等兼容 S3 的存储。标准 AWS S3 留空。",
|
||||
"s3DestGzipLabel": "Gzip 压缩",
|
||||
"s3DestGzipDescription": "使用 gzip 压缩每个上传的对象。减少存储成本和上传大小。",
|
||||
"s3DestFormatTitle": "文件格式",
|
||||
"s3DestFormatDescription": "事件在每个上传对象内的序列化方式。",
|
||||
"s3DestFormatJsonArrayDescription": "每个对象是事件记录的 JSON 数组。兼容大多数分析工具。",
|
||||
"s3DestFormatNdjsonDescription": "每个对象每行包含一个 JSON 记录(换行分隔的 JSON)。兼容 Athena、BigQuery 和 Spark。",
|
||||
"s3DestFormatCsvTitle": "CSV",
|
||||
"s3DestFormatCsvDescription": "每个对象是带有标题行的 RFC-4180 CSV 文件。列名来自事件数据字段。",
|
||||
"s3DestSaveChanges": "保存更改",
|
||||
"s3DestCreateDestination": "创建目的地",
|
||||
"s3DestUpdatedSuccess": "目的地更新成功",
|
||||
"s3DestCreatedSuccess": "目的地创建成功",
|
||||
"s3DestUpdateFailed": "更新目的地失败",
|
||||
"s3DestCreateFailed": "创建目的地失败",
|
||||
"datadogDestEditTitle": "编辑目的地",
|
||||
"datadogDestAddTitle": "添加 Datadog 目的地",
|
||||
"datadogDestEditDescription": "更新此 Datadog 事件流目的地的配置。",
|
||||
|
||||
75
package-lock.json
generated
75
package-lock.json
generated
@@ -57,7 +57,7 @@
|
||||
"d3": "7.9.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.1",
|
||||
"express-rate-limit": "8.3.0",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
@@ -1058,6 +1058,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -2353,6 +2354,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2375,6 +2377,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2397,6 +2400,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2413,6 +2417,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2429,6 +2434,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2445,6 +2451,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2461,6 +2468,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2477,6 +2485,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2493,6 +2502,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2509,6 +2519,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2525,6 +2536,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2541,6 +2553,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2563,6 +2576,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2585,6 +2599,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2607,6 +2622,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2629,6 +2645,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2651,6 +2668,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2673,6 +2691,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2695,6 +2714,7 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -2714,6 +2734,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2733,6 +2754,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2752,6 +2774,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3011,6 +3034,7 @@
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
@@ -6957,6 +6981,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
||||
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -8417,6 +8442,7 @@
|
||||
"version": "5.90.21",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.20"
|
||||
},
|
||||
@@ -8532,6 +8558,7 @@
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -8879,6 +8906,7 @@
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -8974,6 +9002,7 @@
|
||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
@@ -9001,6 +9030,7 @@
|
||||
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -9026,6 +9056,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -9036,6 +9067,7 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -9122,8 +9154,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
@@ -9197,6 +9228,7 @@
|
||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.56.1",
|
||||
"@typescript-eslint/types": "8.56.1",
|
||||
@@ -9670,6 +9702,7 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -10119,6 +10152,7 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -10190,6 +10224,7 @@
|
||||
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -10318,6 +10353,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -11224,6 +11260,7 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -11664,7 +11701,6 @@
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@@ -12299,6 +12335,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -12384,6 +12421,7 @@
|
||||
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
@@ -12520,6 +12558,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -12913,6 +12952,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -12952,12 +12992,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz",
|
||||
"integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -13975,9 +14015,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -15330,7 +15370,6 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -15341,7 +15380,6 @@
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -15430,6 +15468,7 @@
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
|
||||
"integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.15",
|
||||
"@swc/helpers": "0.5.15",
|
||||
@@ -16389,6 +16428,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
@@ -16896,6 +16936,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -16927,6 +16968,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -17219,6 +17261,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -18680,7 +18723,8 @@
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
@@ -19155,6 +19199,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -19582,6 +19627,7 @@
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.8",
|
||||
@@ -19788,6 +19834,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"d3": "7.9.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.5.1",
|
||||
"express-rate-limit": "8.3.0",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export enum ActionsEnum {
|
||||
createOrgUser = "createOrgUser",
|
||||
@@ -152,7 +153,21 @@ export enum ActionsEnum {
|
||||
createHealthCheck = "createHealthCheck",
|
||||
updateHealthCheck = "updateHealthCheck",
|
||||
deleteHealthCheck = "deleteHealthCheck",
|
||||
listHealthChecks = "listHealthChecks"
|
||||
listHealthChecks = "listHealthChecks",
|
||||
listResourcePolicies = "listResourcePolicies",
|
||||
getResourcePolicy = "getResourcePolicy",
|
||||
createResourcePolicy = "createResourcePolicy",
|
||||
updateResourcePolicy = "updateResourcePolicy",
|
||||
deleteResourcePolicy = "deleteResourcePolicy",
|
||||
listResourcePolicyRoles = "listResourcePolicyRoles",
|
||||
setResourcePolicyRoles = "setResourcePolicyRoles",
|
||||
listResourcePolicyUsers = "listResourcePolicyUsers",
|
||||
setResourcePolicyUsers = "setResourcePolicyUsers",
|
||||
setResourcePolicyPassword = "setResourcePolicyPassword",
|
||||
setResourcePolicyPincode = "setResourcePolicyPincode",
|
||||
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||
setResourcePolicyRules = "setResourcePolicyRules"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
@@ -185,6 +200,23 @@ export async function checkUserActionPermission(
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
inArray(roleActions.roleId, userOrgRoleIds),
|
||||
eq(roleActions.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (roleActionPermission.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the user has direct permission for the action in the current org
|
||||
const userActionPermission = await db
|
||||
.select()
|
||||
@@ -202,20 +234,7 @@ export async function checkUserActionPermission(
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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),
|
||||
inArray(roleActions.roleId, userOrgRoleIds),
|
||||
eq(roleActions.orgId, req.userOrgId!)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return roleActionPermission.length > 0;
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error checking user action permission:", error);
|
||||
throw createHttpError(
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { join } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
import { clients, db, resources, siteResources } from "@server/db";
|
||||
import {
|
||||
clients,
|
||||
db,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
siteResources
|
||||
} from "@server/db";
|
||||
import { randomInt } from "crypto";
|
||||
import { exitNodes, sites } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
@@ -107,6 +113,35 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueResourcePolicyName(
|
||||
orgId: string
|
||||
): Promise<string> {
|
||||
let loops = 0;
|
||||
while (true) {
|
||||
if (loops > 100) {
|
||||
throw new Error("Could not generate a unique name");
|
||||
}
|
||||
|
||||
const name = generateName();
|
||||
const policyCount = await db
|
||||
.select({
|
||||
niceId: resourcePolicies.niceId,
|
||||
orgId: resourcePolicies.orgId
|
||||
})
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.niceId, name),
|
||||
eq(resourcePolicies.orgId, orgId)
|
||||
)
|
||||
);
|
||||
if (policyCount.length === 0) {
|
||||
return name;
|
||||
}
|
||||
loops++;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUniqueSiteResourceName(
|
||||
orgId: string
|
||||
): Promise<string> {
|
||||
|
||||
@@ -87,7 +87,7 @@ function createDb() {
|
||||
|
||||
export const db = createDb();
|
||||
export default db;
|
||||
export const primaryDb = db.$primary;
|
||||
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types
|
||||
export type Transaction = Parameters<
|
||||
Parameters<(typeof db)["transaction"]>[0]
|
||||
>[0];
|
||||
|
||||
@@ -332,6 +332,7 @@ export const connectionAuditLog = pgTable(
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
clientEndpoint: text("clientEndpoint"),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
@@ -439,6 +440,8 @@ export const eventStreamingDestinations = pgTable(
|
||||
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),
|
||||
lastError: text("lastError"), // last send error message, null if healthy
|
||||
lastErrorAt: bigint("lastErrorAt", { mode: "number" }), // epoch ms of last error, null if healthy
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||
}
|
||||
|
||||
@@ -110,6 +110,16 @@ export const sites = pgTable("sites", {
|
||||
|
||||
export const resources = pgTable("resources", {
|
||||
resourceId: serial("resourceId").primaryKey(),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
resourceGuid: varchar("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
@@ -196,9 +206,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}).notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
name: varchar("name"),
|
||||
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
||||
hcPath: varchar("hcPath"),
|
||||
@@ -521,6 +533,38 @@ export const userResources = pgTable("userResources", {
|
||||
.references(() => resources.resourceId, { onDelete: "cascade" })
|
||||
});
|
||||
|
||||
export const rolePolicies = pgTable("rolePolicies", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const userPolicies = pgTable("userPolicies", {
|
||||
userId: varchar("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyWhiteList = pgTable("resourcePolicyWhitelist", {
|
||||
whitelistId: serial("id").primaryKey(),
|
||||
email: varchar("email").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const userInvites = pgTable("userInvites", {
|
||||
inviteId: varchar("inviteId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
@@ -586,6 +630,40 @@ export const resourceHeaderAuthExtendedCompatibility = pgTable(
|
||||
}
|
||||
);
|
||||
|
||||
export const resourcePolicyPincode = pgTable("resourcePolicyPincode", {
|
||||
pincodeId: serial("pincodeId").primaryKey(),
|
||||
pincodeHash: varchar("pincodeHash").notNull(),
|
||||
digitLength: integer("digitLength").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyPassword = pgTable("resourcePolicyPassword", {
|
||||
passwordId: serial("passwordId").primaryKey(),
|
||||
passwordHash: varchar("passwordHash").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyHeaderAuth = pgTable("resourcePolicyHeaderAuth", {
|
||||
headerAuthId: serial("headerAuthId").primaryKey(),
|
||||
headerAuthHash: varchar("headerAuthHash").notNull(),
|
||||
extendedCompatibility: boolean("extendedCompatibility")
|
||||
.notNull()
|
||||
.default(true),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourceAccessToken = pgTable("resourceAccessToken", {
|
||||
accessTokenId: varchar("accessTokenId").primaryKey(),
|
||||
orgId: varchar("orgId")
|
||||
@@ -679,6 +757,43 @@ export const resourceRules = pgTable("resourceRules", {
|
||||
value: varchar("value").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicyRules = pgTable("resourcePolicyRules", {
|
||||
ruleId: serial("ruleId").primaryKey(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||
match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
||||
value: varchar("value").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicies = pgTable("resourcePolicies", {
|
||||
resourcePolicyId: serial("resourcePolicyId").primaryKey(),
|
||||
sso: boolean("sso").notNull().default(true),
|
||||
applyRules: boolean("applyRules").notNull().default(false),
|
||||
scope: varchar("scope")
|
||||
.$type<"global" | "resource">()
|
||||
.notNull()
|
||||
.default("global"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
niceId: text("niceId").notNull(),
|
||||
name: varchar("name").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const supporterKey = pgTable("supporterKey", {
|
||||
keyId: serial("keyId").primaryKey(),
|
||||
key: varchar("key").notNull(),
|
||||
@@ -1097,19 +1212,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
||||
complete: boolean("complete").notNull().default(false)
|
||||
});
|
||||
|
||||
export const statusHistory = pgTable("statusHistory", {
|
||||
id: serial("id").primaryKey(),
|
||||
entityType: varchar("entityType").notNull(),
|
||||
entityId: integer("entityId").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: varchar("status").notNull(),
|
||||
timestamp: integer("timestamp").notNull(),
|
||||
}, (table) => [
|
||||
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||
]);
|
||||
export const statusHistory = pgTable(
|
||||
"statusHistory",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
entityType: varchar("entityType").notNull(),
|
||||
entityId: integer("entityId").notNull(),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: varchar("status").notNull(),
|
||||
timestamp: integer("timestamp").notNull()
|
||||
},
|
||||
(table) => [
|
||||
index("idx_statusHistory_entity").on(
|
||||
table.entityType,
|
||||
table.entityId,
|
||||
table.timestamp
|
||||
),
|
||||
index("idx_statusHistory_org_timestamp").on(
|
||||
table.orgId,
|
||||
table.timestamp
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
@@ -1179,3 +1305,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
|
||||
@@ -17,10 +17,13 @@ import {
|
||||
resourceHeaderAuth,
|
||||
ResourceHeaderAuth,
|
||||
resourceRules,
|
||||
resourcePolicyRules,
|
||||
resources,
|
||||
roleResources,
|
||||
rolePolicies,
|
||||
sessions,
|
||||
userResources,
|
||||
userPolicies,
|
||||
users,
|
||||
ResourceHeaderAuthExtendedCompatibility,
|
||||
resourceHeaderAuthExtendedCompatibility
|
||||
@@ -154,58 +157,126 @@ export async function getRoleName(roleId: number): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if role has access to resource
|
||||
* Check if role has access to resource (direct or via resource policy)
|
||||
*/
|
||||
export async function getRoleResourceAccess(
|
||||
resourceId: number,
|
||||
roleIds: number[]
|
||||
) {
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({
|
||||
roleId: rolePolicies.roleId,
|
||||
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourcePolicyId, rolePolicies.resourcePolicyId)
|
||||
)
|
||||
);
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
inArray(rolePolicies.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
return roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
||||
const combined = [...direct, ...viaPolicies];
|
||||
return combined.length > 0 ? combined : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has direct access to resource
|
||||
* Check if user has access to resource (direct or via resource policy)
|
||||
*/
|
||||
export async function getUserResourceAccess(
|
||||
userId: string,
|
||||
resourceId: number
|
||||
) {
|
||||
const userResourceAccess = await db
|
||||
.select()
|
||||
.from(userResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
eq(userResources.resourceId, resourceId)
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(userResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
eq(userResources.resourceId, resourceId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
.limit(1),
|
||||
db
|
||||
.select({
|
||||
userId: userPolicies.userId,
|
||||
resourcePolicyId: userPolicies.resourcePolicyId
|
||||
})
|
||||
.from(userPolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(resources.resourcePolicyId, userPolicies.resourcePolicyId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(userPolicies.userId, userId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
]);
|
||||
|
||||
return userResourceAccess.length > 0 ? userResourceAccess[0] : null;
|
||||
return direct[0] ?? viaPolicies[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource rules for a given resource
|
||||
* Get resource rules for a given resource (direct and via resource policy)
|
||||
*/
|
||||
export async function getResourceRules(
|
||||
resourceId: number
|
||||
): Promise<ResourceRule[]> {
|
||||
const rules = await db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId));
|
||||
const [directRules, policyRules] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId)),
|
||||
db
|
||||
.select({
|
||||
ruleId: resourcePolicyRules.ruleId,
|
||||
resourceId: sql<number>`${resourceId}`,
|
||||
enabled: resourcePolicyRules.enabled,
|
||||
priority: resourcePolicyRules.priority,
|
||||
action: resourcePolicyRules.action,
|
||||
match: resourcePolicyRules.match,
|
||||
value: resourcePolicyRules.value
|
||||
})
|
||||
.from(resourcePolicyRules)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
resourcePolicyRules.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
]);
|
||||
|
||||
return rules;
|
||||
const maxDirectPriority = directRules.reduce(
|
||||
(max, r) => Math.max(max, r.priority),
|
||||
0
|
||||
);
|
||||
const offsetPolicyRules = policyRules.map((r) => ({
|
||||
...r,
|
||||
priority: maxDirectPriority + r.priority
|
||||
}));
|
||||
|
||||
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -332,6 +332,7 @@ export const connectionAuditLog = sqliteTable(
|
||||
clientId: integer("clientId").references(() => clients.clientId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
clientEndpoint: text("clientEndpoint"),
|
||||
userId: text("userId").references(() => users.userId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
@@ -445,6 +446,8 @@ export const eventStreamingDestinations = sqliteTable(
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
lastError: text("lastError"), // last send error message, null if healthy
|
||||
lastErrorAt: integer("lastErrorAt"), // epoch ms of last error, null if healthy
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull()
|
||||
}
|
||||
|
||||
@@ -121,6 +121,16 @@ export const sites = sqliteTable("sites", {
|
||||
|
||||
export const resources = sqliteTable("resources", {
|
||||
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
|
||||
resourcePolicyId: integer("resourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{ onDelete: "set null" }
|
||||
),
|
||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||
() => resourcePolicies.resourcePolicyId,
|
||||
{
|
||||
onDelete: "restrict"
|
||||
}
|
||||
),
|
||||
resourceGuid: text("resourceGuid", { length: 36 })
|
||||
.unique()
|
||||
.notNull()
|
||||
@@ -219,9 +229,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
siteId: integer("siteId").references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
}).notNull(),
|
||||
siteId: integer("siteId")
|
||||
.references(() => sites.siteId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull(),
|
||||
name: text("name"),
|
||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
@@ -909,6 +921,47 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
|
||||
headerAuthHash: text("headerAuthHash").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", {
|
||||
pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }),
|
||||
pincodeHash: text("pincodeHash").notNull(),
|
||||
digitLength: integer("digitLength").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", {
|
||||
passwordId: integer("passwordId").primaryKey({ autoIncrement: true }),
|
||||
passwordHash: text("passwordHash").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyHeaderAuth = sqliteTable(
|
||||
"resourcePolicyHeaderAuth",
|
||||
{
|
||||
headerAuthId: integer("headerAuthId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
headerAuthHash: text("headerAuthHash").notNull(),
|
||||
extendedCompatibility: integer("extendedCompatibility", {
|
||||
mode: "boolean"
|
||||
})
|
||||
.notNull()
|
||||
.default(true),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
|
||||
"resourceHeaderAuthExtendedCompatibility",
|
||||
{
|
||||
@@ -1023,6 +1076,77 @@ export const resourceRules = sqliteTable("resourceRules", {
|
||||
value: text("value").notNull()
|
||||
});
|
||||
|
||||
export const rolePolicies = sqliteTable("rolePolicies", {
|
||||
roleId: integer("roleId")
|
||||
.notNull()
|
||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const userPolicies = sqliteTable("userPolicies", {
|
||||
userId: text("userId")
|
||||
.notNull()
|
||||
.references(() => users.userId, { onDelete: "cascade" }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", {
|
||||
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
|
||||
email: text("email").notNull(),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
});
|
||||
|
||||
export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
||||
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
|
||||
resourcePolicyId: integer("resourcePolicyId")
|
||||
.notNull()
|
||||
.references(() => resourcePolicies.resourcePolicyId, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
priority: integer("priority").notNull(),
|
||||
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
||||
value: text("value").notNull()
|
||||
});
|
||||
|
||||
export const resourcePolicies = sqliteTable("resourcePolicies", {
|
||||
resourcePolicyId: integer("resourcePolicyId").primaryKey(),
|
||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||
applyRules: integer("applyRules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
scope: text("scope")
|
||||
.$type<"global" | "resource">()
|
||||
.notNull()
|
||||
.default("global"),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
niceId: text("niceId").notNull(),
|
||||
idpId: integer("idpId").references(() => idp.idpId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
orgId: text("orgId")
|
||||
.references(() => orgs.orgId, {
|
||||
onDelete: "cascade"
|
||||
})
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const supporterKey = sqliteTable("supporterKey", {
|
||||
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
||||
key: text("key").notNull(),
|
||||
@@ -1196,19 +1320,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export const statusHistory = sqliteTable("statusHistory", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||
timestamp: integer("timestamp").notNull(), // unix epoch seconds
|
||||
}, (table) => [
|
||||
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||
]);
|
||||
export const statusHistory = sqliteTable(
|
||||
"statusHistory",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||
timestamp: integer("timestamp").notNull() // unix epoch seconds
|
||||
},
|
||||
(table) => [
|
||||
index("idx_statusHistory_entity").on(
|
||||
table.entityType,
|
||||
table.entityId,
|
||||
table.timestamp
|
||||
),
|
||||
index("idx_statusHistory_org_timestamp").on(
|
||||
table.orgId,
|
||||
table.timestamp
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
@@ -1278,3 +1413,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
|
||||
export type UserPolicy = InferSelectModel<typeof userPolicies>;
|
||||
|
||||
@@ -24,7 +24,8 @@ export enum TierFeature {
|
||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||
AlertingRules = "alertingRules",
|
||||
WildcardSubdomain = "wildcardSubdomain"
|
||||
WildcardSubdomain = "wildcardSubdomain",
|
||||
ResourcePolicies = "resourcePolicies"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -66,5 +67,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
|
||||
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"]
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -162,9 +162,10 @@ export const HeaderSchema = z.object({
|
||||
});
|
||||
|
||||
// Schema for individual resource
|
||||
export const ResourceSchema = z
|
||||
export const PublicResourceSchema = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
policy: z.string().optional(),
|
||||
protocol: z.enum(["http", "tcp", "udp"]).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
@@ -340,7 +341,8 @@ export const ResourceSchema = z
|
||||
if (parts.includes("*", 1)) return false; // no further wildcards
|
||||
if (parts.length < 3) return false; // need at least *.label.tld
|
||||
|
||||
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||
const labelRegex =
|
||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||
return parts.slice(1).every((label) => labelRegex.test(label));
|
||||
},
|
||||
{
|
||||
@@ -354,7 +356,7 @@ export function isTargetsOnlyResource(resource: any): boolean {
|
||||
return Object.keys(resource).length === 1 && resource.targets;
|
||||
}
|
||||
|
||||
export const ClientResourceSchema = z
|
||||
export const PrivateResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr", "http"]),
|
||||
@@ -435,19 +437,19 @@ export const ClientResourceSchema = z
|
||||
export const ConfigSchema = z
|
||||
.object({
|
||||
"proxy-resources": z
|
||||
.record(z.string(), ResourceSchema)
|
||||
.record(z.string(), PublicResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
"public-resources": z
|
||||
.record(z.string(), ResourceSchema)
|
||||
.record(z.string(), PublicResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
"client-resources": z
|
||||
.record(z.string(), ClientResourceSchema)
|
||||
.record(z.string(), PrivateResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
"private-resources": z
|
||||
.record(z.string(), ClientResourceSchema)
|
||||
.record(z.string(), PrivateResourceSchema)
|
||||
.optional()
|
||||
.prefault({}),
|
||||
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
||||
@@ -472,10 +474,13 @@ export const ConfigSchema = z
|
||||
}
|
||||
|
||||
return data as {
|
||||
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>;
|
||||
"proxy-resources": Record<
|
||||
string,
|
||||
z.infer<typeof PublicResourceSchema>
|
||||
>;
|
||||
"client-resources": Record<
|
||||
string,
|
||||
z.infer<typeof ClientResourceSchema>
|
||||
z.infer<typeof PrivateResourceSchema>
|
||||
>;
|
||||
sites: Record<string, z.infer<typeof SiteSchema>>;
|
||||
};
|
||||
@@ -614,5 +619,5 @@ export const ConfigSchema = z
|
||||
// Type inference from the schema
|
||||
export type Site = z.infer<typeof SiteSchema>;
|
||||
export type Target = z.infer<typeof TargetSchema>;
|
||||
export type Resource = z.infer<typeof ResourceSchema>;
|
||||
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
@@ -25,9 +25,9 @@ import { tierMatrix } from "./billing/tierMatrix";
|
||||
|
||||
export async function calculateUserClientsForOrgs(
|
||||
userId: string,
|
||||
trx?: Transaction
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
const execute = async (transaction: Transaction) => {
|
||||
const execute = async (transaction: Transaction | typeof db) => {
|
||||
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
||||
const adminRoleCache = new Map<
|
||||
string,
|
||||
@@ -437,7 +437,7 @@ export async function calculateUserClientsForOrgs(
|
||||
|
||||
async function cleanupOrphanedClients(
|
||||
userId: string,
|
||||
trx: Transaction,
|
||||
trx: Transaction | typeof db,
|
||||
userOrgIds: string[] = []
|
||||
): Promise<void> {
|
||||
// Find all OLM clients for this user that should be deleted
|
||||
|
||||
@@ -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.18.3";
|
||||
export const APP_VERSION = "1.18.4";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
||||
@@ -124,7 +124,7 @@ export function computeBuckets(
|
||||
let totalDowntime = 0;
|
||||
|
||||
for (let d = 0; d < days; d++) {
|
||||
const dayStartSec = todayMidnightSec - (days - d) * 86400;
|
||||
const dayStartSec = todayMidnightSec - (days - 1 - d) * 86400;
|
||||
const dayEndSec = dayStartSec + 86400;
|
||||
|
||||
const dayEvents = events.filter(
|
||||
|
||||
@@ -32,3 +32,4 @@ export * from "./verifySiteResourceAccess";
|
||||
export * from "./logActionAudit";
|
||||
export * from "./verifyOlmAccess";
|
||||
export * from "./verifyLimits";
|
||||
export * from "./verifyResourcePolicyAccess";
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from "./verifyApiKeyClientAccess";
|
||||
export * from "./verifyApiKeySiteResourceAccess";
|
||||
export * from "./verifyApiKeyIdpAccess";
|
||||
export * from "./verifyApiKeyDomainAccess";
|
||||
export * from "./verifyApiKeyResourcePolicyAccess";
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicies, apiKeyOrg } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyApiKeyResourcePolicyAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const apiKey = req.apiKey;
|
||||
const resourcePolicyId =
|
||||
req.params.resourcePolicyId ||
|
||||
req.body.resourcePolicyId ||
|
||||
req.query.resourcePolicyId;
|
||||
|
||||
if (!apiKey) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Retrieve the resource policy
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (apiKey.isRoot) {
|
||||
// Root keys can access any resource policy in any org
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!policy.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
`Resource policy with ID ${resourcePolicyId} does not have an organization ID`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that the API key is linked to the resource policy's organization
|
||||
if (!req.apiKeyOrg) {
|
||||
const apiKeyOrgResult = await db
|
||||
.select()
|
||||
.from(apiKeyOrg)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
||||
eq(apiKeyOrg.orgId, policy.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (apiKeyOrgResult.length > 0) {
|
||||
req.apiKeyOrg = apiKeyOrgResult[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.apiKeyOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Key does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying resource policy access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
127
server/middlewares/verifyResourcePolicyAccess.ts
Normal file
127
server/middlewares/verifyResourcePolicyAccess.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicies, 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 verifyResourcePolicyAccess(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const userId = req.user!.userId;
|
||||
const resourcePolicyIdStr =
|
||||
req.params?.resourcePolicyId ||
|
||||
req.body?.resourcePolicyId ||
|
||||
req.query?.resourcePolicyId;
|
||||
const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId;
|
||||
const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId;
|
||||
|
||||
try {
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
let policy: typeof resourcePolicies.$inferSelect | null = null;
|
||||
|
||||
if (orgId && niceId) {
|
||||
const [policyRes] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.niceId, niceId),
|
||||
eq(resourcePolicies.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
policy = policyRes ?? null;
|
||||
} else {
|
||||
const resourcePolicyId = parseInt(resourcePolicyIdStr);
|
||||
if (isNaN(resourcePolicyId)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid resource policy ID"
|
||||
)
|
||||
);
|
||||
}
|
||||
const [policyRes] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
policy = policyRes ?? null;
|
||||
}
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${resourcePolicyIdStr ?? niceId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.userOrg) {
|
||||
const userOrgRes = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.userId, userId),
|
||||
eq(userOrgs.orgId, policy.orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
req.userOrg = userOrgRes[0];
|
||||
}
|
||||
|
||||
if (!req.userOrg || req.userOrg.orgId !== policy.orgId) {
|
||||
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,
|
||||
policy.orgId
|
||||
);
|
||||
req.userOrgId = policy.orgId;
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying resource policy access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function verifyUserCanSetUserOrgRoles() {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
"User does not have permission to set user organization roles"
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum OpenAPITags {
|
||||
Org = "Organization",
|
||||
PublicResource = "Public Resource",
|
||||
PrivateResource = "Private Resource",
|
||||
Policy = "Policy",
|
||||
Role = "Role",
|
||||
User = "User",
|
||||
Invitation = "User Invitation",
|
||||
|
||||
@@ -485,6 +485,133 @@ async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function storeCertForDomain(
|
||||
domain: string,
|
||||
certPem: string,
|
||||
keyPem: string,
|
||||
validatedX509: crypto.X509Certificate
|
||||
): Promise<void> {
|
||||
const wildcard = domain.startsWith("*.");
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.domain, domain))
|
||||
.limit(1);
|
||||
|
||||
let oldCertPem: string | null = null;
|
||||
let oldKeyPem: string | null = null;
|
||||
|
||||
if (existing.length > 0 && existing[0].certFile) {
|
||||
try {
|
||||
const storedCertPem = decrypt(
|
||||
existing[0].certFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||
return;
|
||||
}
|
||||
oldCertPem = storedCertPem;
|
||||
if (existing[0].keyFile) {
|
||||
try {
|
||||
oldKeyPem = decrypt(
|
||||
existing[0].keyFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
} catch (keyErr) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let expiresAt: number | null = null;
|
||||
try {
|
||||
expiresAt = Math.floor(
|
||||
new Date(validatedX509.validTo).getTime() / 1000
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedCert = encrypt(
|
||||
certPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const encryptedKey = encrypt(keyPem, config.getRawConfig().server.secret!);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const domainId = await findDomainId(domain);
|
||||
if (domainId) {
|
||||
logger.debug(
|
||||
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
wildcard,
|
||||
...(domainId !== null && { domainId })
|
||||
})
|
||||
.where(eq(certificates.domain, domain));
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
await pushCertUpdateToAffectedNewts(
|
||||
domain,
|
||||
domainId,
|
||||
oldCertPem,
|
||||
oldKeyPem
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db.insert(certificates).values({
|
||||
domain,
|
||||
domainId,
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
wildcard
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
function findAcmeJsonFiles(dirPath: string): string[] {
|
||||
const results: string[] = [];
|
||||
let entries: fs.Dirent[];
|
||||
@@ -575,18 +702,16 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
for (const cert of allCerts) {
|
||||
const domain = cert?.domain?.main;
|
||||
const mainDomain = cert?.domain?.main;
|
||||
|
||||
if (!domain || typeof domain !== "string") {
|
||||
if (!mainDomain || typeof mainDomain !== "string") {
|
||||
logger.debug(`acmeCertSync: skipping cert with missing domain`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { wildcard } = detectWildcard(domain, cert.domain?.sans);
|
||||
|
||||
if (!cert.certificate || !cert.key) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - empty certificate or key field`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -598,14 +723,14 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - failed to base64-decode cert/key: ${err}`
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - failed to base64-decode cert/key: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!certPem.trim() || !keyPem.trim()) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - blank PEM after base64 decode`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -616,7 +741,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
const firstCertPemForValidation = extractFirstCert(certPem);
|
||||
if (!firstCertPemForValidation) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - no PEM certificate block found`
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - no PEM certificate block found`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -628,7 +753,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - invalid X.509 certificate: ${err}`
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - invalid X.509 certificate: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -638,139 +763,40 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
|
||||
crypto.createPrivateKey(keyPem);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: skipping cert for ${domain} - invalid private key: ${err}`
|
||||
`acmeCertSync: skipping cert for ${mainDomain} - invalid private key: ${err}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if cert already exists in DB
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(and(eq(certificates.domain, domain)))
|
||||
.limit(1);
|
||||
|
||||
let oldCertPem: string | null = null;
|
||||
let oldKeyPem: string | null = null;
|
||||
|
||||
if (existing.length > 0 && existing[0].certFile) {
|
||||
try {
|
||||
const storedCertPem = decrypt(
|
||||
existing[0].certFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const wildcardUnchanged = existing[0].wildcard === wildcard;
|
||||
if (storedCertPem === certPem && wildcardUnchanged) {
|
||||
// logger.debug(
|
||||
// `acmeCertSync: cert for ${domain} is unchanged, skipping`
|
||||
// );
|
||||
continue;
|
||||
// Collect all domains covered by this cert: main + every SAN.
|
||||
// Each domain gets its own row in the certificates table so that
|
||||
// lookups by any hostname on the cert succeed independently.
|
||||
const allDomains = new Set<string>([mainDomain]);
|
||||
if (Array.isArray(cert.domain?.sans)) {
|
||||
for (const san of cert.domain.sans) {
|
||||
if (typeof san === "string" && san.trim()) {
|
||||
allDomains.add(san.trim());
|
||||
}
|
||||
// Cert has changed; capture old values so we can send a correct
|
||||
// update message to the newt after the DB write.
|
||||
oldCertPem = storedCertPem;
|
||||
if (existing[0].keyFile) {
|
||||
try {
|
||||
oldKeyPem = decrypt(
|
||||
existing[0].keyFile,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
} catch (keyErr) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Decryption failure means we should proceed with the update
|
||||
logger.debug(
|
||||
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse cert expiry from the validated X.509 certificate
|
||||
let expiresAt: number | null = null;
|
||||
try {
|
||||
expiresAt = Math.floor(
|
||||
new Date(validatedX509.validTo).getTime() / 1000
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedCert = encrypt(
|
||||
certPem,
|
||||
config.getRawConfig().server.secret!
|
||||
logger.debug(
|
||||
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
|
||||
);
|
||||
const encryptedKey = encrypt(
|
||||
keyPem,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const domainId = await findDomainId(domain);
|
||||
if (domainId) {
|
||||
logger.debug(
|
||||
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.length > 0) {
|
||||
logger.debug(
|
||||
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db
|
||||
.update(certificates)
|
||||
.set({
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
wildcard,
|
||||
...(domainId !== null && { domainId })
|
||||
})
|
||||
.where(eq(certificates.domain, domain));
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
await pushCertUpdateToAffectedNewts(
|
||||
domain,
|
||||
domainId,
|
||||
oldCertPem,
|
||||
oldKeyPem
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
await db.insert(certificates).values({
|
||||
domain,
|
||||
domainId,
|
||||
certFile: encryptedCert,
|
||||
keyFile: encryptedKey,
|
||||
status: "valid",
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
wildcard
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||
);
|
||||
|
||||
// For a brand-new cert, push to any SSL resources that were waiting for it
|
||||
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
|
||||
for (const domain of allDomains) {
|
||||
try {
|
||||
await storeCertForDomain(
|
||||
domain,
|
||||
certPem,
|
||||
keyPem,
|
||||
validatedX509
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`acmeCertSync: error storing cert for domain "${domain}": ${err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,13 @@ export class PrivateConfig {
|
||||
);
|
||||
}
|
||||
|
||||
process.env.BRANDING_HIDE_POWERED_BY =
|
||||
this.rawPrivateConfig.branding?.hide_powered_by === true ||
|
||||
this.rawPrivateConfig.branding?.resource_auth_page
|
||||
?.hide_powered_by === true
|
||||
? "true"
|
||||
: "false";
|
||||
|
||||
process.env.LOGIN_PAGE_SUBTITLE_TEXT =
|
||||
this.rawPrivateConfig.branding?.login_page?.subtitle_text || "";
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface ConnectionLogRecord {
|
||||
orgId: string;
|
||||
siteId: number;
|
||||
clientId: number | null;
|
||||
clientEndpoint: string | null;
|
||||
userId: string | null;
|
||||
sourceAddr: string;
|
||||
destAddr: string;
|
||||
|
||||
@@ -30,10 +30,12 @@ import {
|
||||
LOG_TYPES,
|
||||
LogEvent,
|
||||
DestinationFailureState,
|
||||
HttpConfig
|
||||
HttpConfig,
|
||||
S3Config
|
||||
} from "./types";
|
||||
import { LogDestinationProvider } from "./providers/LogDestinationProvider";
|
||||
import { HttpLogDestination } from "./providers/HttpLogDestination";
|
||||
import { S3LogDestination } from "./providers/S3LogDestination";
|
||||
import type { EventStreamingDestination } from "@server/db";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -72,11 +74,11 @@ const MAX_CATCHUP_BATCHES = 20;
|
||||
* 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+)
|
||||
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+)
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -204,7 +206,10 @@ export class LogStreamingManager {
|
||||
this.pollTimer = null;
|
||||
this.runPoll()
|
||||
.catch((err) =>
|
||||
logger.error("LogStreamingManager: unexpected poll error", err)
|
||||
logger.error(
|
||||
"LogStreamingManager: unexpected poll error",
|
||||
err
|
||||
)
|
||||
)
|
||||
.finally(() => {
|
||||
if (this.isRunning) {
|
||||
@@ -275,10 +280,13 @@ export class LogStreamingManager {
|
||||
}
|
||||
|
||||
// Decrypt and parse config – skip destination if either step fails
|
||||
let configFromDb: HttpConfig;
|
||||
let configFromDb: unknown;
|
||||
try {
|
||||
const decryptedConfig = decrypt(dest.config, config.getRawConfig().server.secret!);
|
||||
configFromDb = JSON.parse(decryptedConfig) as HttpConfig;
|
||||
const decryptedConfig = decrypt(
|
||||
dest.config,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
configFromDb = JSON.parse(decryptedConfig);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`LogStreamingManager: destination ${dest.destinationId} has invalid or undecryptable config`,
|
||||
@@ -305,6 +313,7 @@ export class LogStreamingManager {
|
||||
if (enabledTypes.length === 0) return;
|
||||
|
||||
let anyFailure = false;
|
||||
let firstError: string | null = null;
|
||||
|
||||
for (const logType of enabledTypes) {
|
||||
if (!this.isRunning) break;
|
||||
@@ -312,6 +321,10 @@ export class LogStreamingManager {
|
||||
await this.processLogType(dest, provider, logType);
|
||||
} catch (err) {
|
||||
anyFailure = true;
|
||||
if (firstError === null) {
|
||||
firstError =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
logger.error(
|
||||
`LogStreamingManager: failed to process "${logType}" logs ` +
|
||||
`for destination ${dest.destinationId}`,
|
||||
@@ -322,6 +335,10 @@ export class LogStreamingManager {
|
||||
|
||||
if (anyFailure) {
|
||||
this.recordFailure(dest.destinationId);
|
||||
await this.setDestinationError(
|
||||
dest.destinationId,
|
||||
firstError ?? "Unknown error"
|
||||
);
|
||||
} else {
|
||||
// Any success resets the failure/back-off state
|
||||
if (this.failures.has(dest.destinationId)) {
|
||||
@@ -330,6 +347,7 @@ export class LogStreamingManager {
|
||||
`LogStreamingManager: destination ${dest.destinationId} recovered`
|
||||
);
|
||||
}
|
||||
await this.clearDestinationError(dest.destinationId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,7 +380,10 @@ export class LogStreamingManager {
|
||||
.from(eventStreamingCursors)
|
||||
.where(
|
||||
and(
|
||||
eq(eventStreamingCursors.destinationId, dest.destinationId),
|
||||
eq(
|
||||
eventStreamingCursors.destinationId,
|
||||
dest.destinationId
|
||||
),
|
||||
eq(eventStreamingCursors.logType, logType)
|
||||
)
|
||||
)
|
||||
@@ -431,9 +452,7 @@ export class LogStreamingManager {
|
||||
|
||||
if (rows.length === 0) break;
|
||||
|
||||
const events = rows.map((row) =>
|
||||
this.rowToLogEvent(logType, row)
|
||||
);
|
||||
const events = rows.map((row) => this.rowToLogEvent(logType, row));
|
||||
|
||||
// Throws on failure – caught by the caller which applies back-off
|
||||
await provider.send(events);
|
||||
@@ -677,8 +696,7 @@ export class LogStreamingManager {
|
||||
break;
|
||||
}
|
||||
|
||||
const orgId =
|
||||
typeof row.orgId === "string" ? row.orgId : "";
|
||||
const orgId = typeof row.orgId === "string" ? row.orgId : "";
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -708,6 +726,8 @@ export class LogStreamingManager {
|
||||
switch (type) {
|
||||
case "http":
|
||||
return new HttpLogDestination(config as HttpConfig);
|
||||
case "s3":
|
||||
return new S3LogDestination(config as S3Config);
|
||||
// Future providers:
|
||||
// case "datadog": return new DatadogLogDestination(config as DatadogConfig);
|
||||
default:
|
||||
@@ -749,6 +769,45 @@ export class LogStreamingManager {
|
||||
// DB helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async setDestinationError(
|
||||
destinationId: number,
|
||||
errorMessage: string
|
||||
): Promise<void> {
|
||||
// Truncate to 1000 chars so it fits comfortably in the text column.
|
||||
const truncated = errorMessage.slice(0, 1000);
|
||||
try {
|
||||
await db
|
||||
.update(eventStreamingDestinations)
|
||||
.set({ lastError: truncated, lastErrorAt: Date.now() })
|
||||
.where(
|
||||
eq(eventStreamingDestinations.destinationId, destinationId)
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`LogStreamingManager: could not persist error status for destination ${destinationId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async clearDestinationError(destinationId: number): Promise<void> {
|
||||
try {
|
||||
// Only update if there is actually an error stored, to avoid
|
||||
// unnecessary writes on every successful poll cycle.
|
||||
await db
|
||||
.update(eventStreamingDestinations)
|
||||
.set({ lastError: null, lastErrorAt: null })
|
||||
.where(
|
||||
eq(eventStreamingDestinations.destinationId, destinationId)
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`LogStreamingManager: could not clear error status for destination ${destinationId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadEnabledDestinations(): Promise<
|
||||
EventStreamingDestination[]
|
||||
> {
|
||||
|
||||
279
server/private/lib/logStreaming/providers/S3LogDestination.ts
Normal file
279
server/private/lib/logStreaming/providers/S3LogDestination.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { gzip as gzipCallback } from "zlib";
|
||||
import { promisify } from "util";
|
||||
import { randomUUID } from "crypto";
|
||||
import logger from "@server/logger";
|
||||
import { LogEvent, S3Config, S3PayloadFormat } from "../types";
|
||||
import { LogDestinationProvider } from "./LogDestinationProvider";
|
||||
|
||||
const gzipAsync = promisify(gzipCallback);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum time (ms) to wait for a single S3 PutObject response. */
|
||||
const REQUEST_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Default payload format when none is specified in the config. */
|
||||
const DEFAULT_FORMAT: S3PayloadFormat = "json_array";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S3LogDestination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Forwards a batch of log events to an S3-compatible object store by
|
||||
* uploading a single object per `send()` call.
|
||||
*
|
||||
* **Object key layout**
|
||||
* ```
|
||||
* {prefix}/{logType}/{YYYY}/{MM}/{DD}/{HH}-{mm}-{ss}-{uuid}.{ext}[.gz]
|
||||
* ```
|
||||
* - `prefix` – from `config.prefix` (default: empty – key starts at logType)
|
||||
* - `logType` – one of "request", "action", "access", "connection"
|
||||
* - Date components are derived from the upload time (UTC)
|
||||
* - `ext` – `json` | `ndjson` | `csv`
|
||||
* - `.gz` – appended when `config.gzip` is true
|
||||
*
|
||||
* **Payload formats** (controlled by `config.format`):
|
||||
* - `json_array` (default) – body is a JSON array of event objects.
|
||||
* - `ndjson` – one JSON object per line (newline-delimited).
|
||||
* - `csv` – RFC-4180 CSV with a header row; columns are the
|
||||
* union of all field names in the batch's event data.
|
||||
*
|
||||
* **Compression**: when `config.gzip` is `true` the body is gzip-compressed
|
||||
* before upload and `Content-Encoding: gzip` is set on the object.
|
||||
*
|
||||
* **Custom endpoint**: set `config.endpoint` to target any S3-compatible
|
||||
* storage service (e.g. MinIO, Cloudflare R2).
|
||||
*/
|
||||
export class S3LogDestination implements LogDestinationProvider {
|
||||
readonly type = "s3";
|
||||
|
||||
private readonly config: S3Config;
|
||||
|
||||
constructor(config: S3Config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// LogDestinationProvider implementation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async send(events: LogEvent[]): Promise<void> {
|
||||
if (events.length === 0) return;
|
||||
|
||||
const format = this.config.format ?? DEFAULT_FORMAT;
|
||||
const useGzip = this.config.gzip ?? false;
|
||||
const logType = events[0].logType;
|
||||
|
||||
const rawBody = this.serialize(events, format);
|
||||
const bodyBuffer = Buffer.from(rawBody, "utf-8");
|
||||
|
||||
let uploadBody: Buffer;
|
||||
let contentEncoding: string | undefined;
|
||||
|
||||
if (useGzip) {
|
||||
uploadBody = (await gzipAsync(bodyBuffer)) as Buffer;
|
||||
contentEncoding = "gzip";
|
||||
} else {
|
||||
uploadBody = bodyBuffer;
|
||||
}
|
||||
|
||||
const key = this.buildObjectKey(logType, format, useGzip);
|
||||
const contentType = this.contentType(format);
|
||||
|
||||
const clientConfig: ConstructorParameters<typeof S3Client>[0] = {
|
||||
region: this.config.region,
|
||||
credentials: {
|
||||
accessKeyId: this.config.accessKeyId,
|
||||
secretAccessKey: this.config.secretAccessKey
|
||||
},
|
||||
requestHandler: {
|
||||
requestTimeout: REQUEST_TIMEOUT_MS
|
||||
}
|
||||
};
|
||||
|
||||
if (this.config.endpoint?.trim()) {
|
||||
clientConfig.endpoint = this.config.endpoint.trim();
|
||||
}
|
||||
|
||||
const client = new S3Client(clientConfig);
|
||||
|
||||
try {
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.config.bucket,
|
||||
Key: key,
|
||||
Body: uploadBody,
|
||||
ContentType: contentType,
|
||||
...(contentEncoding
|
||||
? { ContentEncoding: contentEncoding }
|
||||
: {})
|
||||
})
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`S3LogDestination: failed to upload object "${key}" ` +
|
||||
`to bucket "${this.config.bucket}" – ${msg}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Construct a unique S3 object key for the given log type and format.
|
||||
* Keys are partitioned by logType and date so they can be queried or
|
||||
* lifecycle-managed independently.
|
||||
*/
|
||||
private buildObjectKey(
|
||||
logType: string,
|
||||
format: S3PayloadFormat,
|
||||
gzip: boolean
|
||||
): string {
|
||||
const now = new Date();
|
||||
const year = now.getUTCFullYear();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(now.getUTCHours()).padStart(2, "0");
|
||||
const mm = String(now.getUTCMinutes()).padStart(2, "0");
|
||||
const ss = String(now.getUTCSeconds()).padStart(2, "0");
|
||||
const uid = randomUUID();
|
||||
|
||||
const ext =
|
||||
format === "csv" ? "csv" : format === "ndjson" ? "ndjson" : "json";
|
||||
const fileName = `${hh}-${mm}-${ss}-${uid}.${ext}${gzip ? ".gz" : ""}`;
|
||||
|
||||
const rawPrefix = (this.config.prefix ?? "").trim().replace(/\/+$/, "");
|
||||
const parts = [
|
||||
rawPrefix,
|
||||
logType,
|
||||
`${year}/${month}/${day}`,
|
||||
fileName
|
||||
].filter((p) => p !== "");
|
||||
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
private contentType(format: S3PayloadFormat): string {
|
||||
switch (format) {
|
||||
case "csv":
|
||||
return "text/csv; charset=utf-8";
|
||||
case "ndjson":
|
||||
return "application/x-ndjson";
|
||||
default:
|
||||
return "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
private serialize(events: LogEvent[], format: S3PayloadFormat): string {
|
||||
switch (format) {
|
||||
case "json_array":
|
||||
return JSON.stringify(events.map(toPayload));
|
||||
case "ndjson":
|
||||
return events
|
||||
.map((e) => JSON.stringify(toPayload(e)))
|
||||
.join("\n");
|
||||
case "csv":
|
||||
return toCsv(events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Payload helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toPayload(event: LogEvent): unknown {
|
||||
return {
|
||||
event: event.logType,
|
||||
timestamp: new Date(event.timestamp * 1000).toISOString(),
|
||||
data: event.data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a batch of events to RFC-4180 CSV.
|
||||
*
|
||||
* The column set is the union of `event`, `timestamp`, and all keys present in
|
||||
* `event.data` across the batch, preserving insertion order. Values that
|
||||
* contain commas, double-quotes, or newlines are quoted and escaped.
|
||||
*/
|
||||
function toCsv(events: LogEvent[]): string {
|
||||
if (events.length === 0) return "";
|
||||
|
||||
// Collect all unique data keys in stable order
|
||||
const keySet = new LinkedSet<string>();
|
||||
keySet.add("event");
|
||||
keySet.add("timestamp");
|
||||
for (const e of events) {
|
||||
for (const k of Object.keys(e.data)) {
|
||||
keySet.add(k);
|
||||
}
|
||||
}
|
||||
const headers = keySet.toArray();
|
||||
|
||||
const rows: string[] = [headers.map(csvEscape).join(",")];
|
||||
|
||||
for (const e of events) {
|
||||
const flat: Record<string, unknown> = {
|
||||
event: e.logType,
|
||||
timestamp: new Date(e.timestamp * 1000).toISOString(),
|
||||
...e.data
|
||||
};
|
||||
rows.push(
|
||||
headers.map((h) => csvEscape(flattenValue(flat[h]))).join(",")
|
||||
);
|
||||
}
|
||||
|
||||
return rows.join("\n");
|
||||
}
|
||||
|
||||
/** Flatten a value to a plain string suitable for a CSV cell. */
|
||||
function flattenValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** RFC-4180 CSV escaping. */
|
||||
function csvEscape(value: string): string {
|
||||
if (/[",\n\r]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal ordered set (preserves insertion order, deduplicates)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class LinkedSet<T> {
|
||||
private readonly map = new Map<T, true>();
|
||||
|
||||
add(value: T): void {
|
||||
this.map.set(value, true);
|
||||
}
|
||||
|
||||
toArray(): T[] {
|
||||
return Array.from(this.map.keys());
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,40 @@ export interface HttpConfig {
|
||||
bodyTemplate?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S3 destination configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Controls how the batch of events is serialised into each S3 object.
|
||||
*
|
||||
* - `json_array` – `[{…}, {…}]` – default; each object is a JSON array.
|
||||
* - `ndjson` – `{…}\n{…}` – newline-delimited JSON, one object per line.
|
||||
* - `csv` – RFC-4180 CSV with a header row derived from the event fields.
|
||||
*/
|
||||
export type S3PayloadFormat = "json_array" | "ndjson" | "csv";
|
||||
|
||||
export interface S3Config {
|
||||
/** Human-readable label for the destination */
|
||||
name: string;
|
||||
/** AWS Access Key ID */
|
||||
accessKeyId: string;
|
||||
/** AWS Secret Access Key */
|
||||
secretAccessKey: string;
|
||||
/** AWS region (e.g. "us-east-1") */
|
||||
region: string;
|
||||
/** Target S3 bucket name */
|
||||
bucket: string;
|
||||
/** Optional key prefix – appended before the auto-generated path */
|
||||
prefix?: string;
|
||||
/** Override the S3 endpoint for S3-compatible storage (e.g. MinIO, R2) */
|
||||
endpoint?: string;
|
||||
/** How events are serialised into each object. Defaults to "json_array". */
|
||||
format: S3PayloadFormat;
|
||||
/** Whether to gzip-compress the object before upload. */
|
||||
gzip: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-destination per-log-type cursor (reflects the DB table)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -141,6 +141,7 @@ export const privateConfigSchema = z
|
||||
)
|
||||
.optional(),
|
||||
hide_auth_layout_footer: z.boolean().optional().default(false),
|
||||
hide_powered_by: z.boolean().optional(),
|
||||
login_page: z
|
||||
.object({
|
||||
subtitle_text: z.string().optional()
|
||||
|
||||
@@ -124,15 +124,11 @@ function getWhere(data: Q) {
|
||||
data.clientId
|
||||
? eq(connectionAuditLog.clientId, data.clientId)
|
||||
: undefined,
|
||||
data.siteId
|
||||
? eq(connectionAuditLog.siteId, data.siteId)
|
||||
: 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
|
||||
data.userId ? eq(connectionAuditLog.userId, data.userId) : undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,6 +140,7 @@ export function queryConnection(data: Q) {
|
||||
orgId: connectionAuditLog.orgId,
|
||||
siteId: connectionAuditLog.siteId,
|
||||
clientId: connectionAuditLog.clientId,
|
||||
clientEndpoint: connectionAuditLog.clientEndpoint,
|
||||
userId: connectionAuditLog.userId,
|
||||
sourceAddr: connectionAuditLog.sourceAddr,
|
||||
destAddr: connectionAuditLog.destAddr,
|
||||
@@ -203,10 +200,7 @@ async function enrichWithDetails(
|
||||
];
|
||||
|
||||
// Fetch resource details from main database
|
||||
const resourceMap = new Map<
|
||||
number,
|
||||
{ name: string; niceId: string }
|
||||
>();
|
||||
const resourceMap = new Map<number, { name: string; niceId: string }>();
|
||||
if (siteResourceIds.length > 0) {
|
||||
const resourceDetails = await primaryDb
|
||||
.select({
|
||||
@@ -268,10 +262,7 @@ async function enrichWithDetails(
|
||||
}
|
||||
|
||||
// Fetch user details from main database
|
||||
const userMap = new Map<
|
||||
string,
|
||||
{ email: string | null }
|
||||
>();
|
||||
const userMap = new Map<string, { email: string | null }>();
|
||||
if (userIds.length > 0) {
|
||||
const userDetails = await primaryDb
|
||||
.select({
|
||||
@@ -290,29 +281,25 @@ async function enrichWithDetails(
|
||||
return logs.map((log) => ({
|
||||
...log,
|
||||
resourceName: log.siteResourceId
|
||||
? resourceMap.get(log.siteResourceId)?.name ?? null
|
||||
? (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
|
||||
? (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
|
||||
? (siteMap.get(log.siteId)?.niceId ?? null)
|
||||
: null,
|
||||
clientName: log.clientId
|
||||
? clientMap.get(log.clientId)?.name ?? null
|
||||
? (clientMap.get(log.clientId)?.name ?? null)
|
||||
: null,
|
||||
clientNiceId: log.clientId
|
||||
? clientMap.get(log.clientId)?.niceId ?? null
|
||||
? (clientMap.get(log.clientId)?.niceId ?? null)
|
||||
: null,
|
||||
clientType: log.clientId
|
||||
? clientMap.get(log.clientId)?.type ?? null
|
||||
? (clientMap.get(log.clientId)?.type ?? null)
|
||||
: null,
|
||||
userEmail: log.userId
|
||||
? userMap.get(log.userId)?.email ?? null
|
||||
: null
|
||||
userEmail: log.userId ? (userMap.get(log.userId)?.email ?? null) : null
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -521,4 +508,4 @@ export async function queryConnectionAuditLogs(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ export type ListEventStreamingDestinationsResponse = {
|
||||
type: string;
|
||||
config: string;
|
||||
enabled: boolean;
|
||||
lastError: string | null;
|
||||
lastErrorAt: number | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
sendConnectionLogs: boolean;
|
||||
@@ -79,7 +81,8 @@ async function query(orgId: string, limit: number, offset: number) {
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/event-streaming-destination",
|
||||
description: "List all event streaming destinations for a specific organization.",
|
||||
description:
|
||||
"List all event streaming destinations for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
query: querySchema,
|
||||
|
||||
@@ -31,6 +31,8 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||
import * as alertRule from "#private/routers/alertRule";
|
||||
import * as healthChecks from "#private/routers/healthChecks";
|
||||
import * as resource from "#private/routers/resource";
|
||||
import * as policy from "#private/routers/policy";
|
||||
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
@@ -44,7 +46,8 @@ import {
|
||||
verifyUserCanSetUserOrgRoles,
|
||||
verifySiteProvisioningKeyAccess,
|
||||
verifyIsLoggedInUser,
|
||||
verifyAdmin
|
||||
verifyAdmin,
|
||||
verifyResourcePolicyAccess
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import {
|
||||
@@ -382,6 +385,39 @@ authenticated.get(
|
||||
approval.countApprovals
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.deleteResourcePolicy),
|
||||
logActionAudit(ActionsEnum.deleteResourcePolicy),
|
||||
policy.deleteResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource-policies",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.listResourcePolicies),
|
||||
logActionAudit(ActionsEnum.listResourcePolicies),
|
||||
policy.listResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/resource-policy",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.resourcePolicies),
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createResourcePolicy),
|
||||
logActionAudit(ActionsEnum.createResourcePolicy),
|
||||
policy.createResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/approvals/:approvalId",
|
||||
verifyValidLicense,
|
||||
|
||||
@@ -45,8 +45,11 @@ import {
|
||||
users,
|
||||
userOrgs,
|
||||
roleResources,
|
||||
rolePolicies,
|
||||
userResources,
|
||||
userPolicies,
|
||||
resourceRules,
|
||||
resourcePolicyRules,
|
||||
userOrgRoles,
|
||||
roles
|
||||
} from "@server/db";
|
||||
@@ -430,7 +433,10 @@ hybridRouter.get(
|
||||
);
|
||||
|
||||
// Decrypt and save key file
|
||||
const decryptedKey = decrypt(cert.keyFile!, config.getRawConfig().server.secret!);
|
||||
const decryptedKey = decrypt(
|
||||
cert.keyFile!,
|
||||
config.getRawConfig().server.secret!
|
||||
);
|
||||
|
||||
// Return only the certificate data without org information
|
||||
return {
|
||||
@@ -531,7 +537,10 @@ hybridRouter.get(
|
||||
wildcardCandidates.length > 0
|
||||
? and(
|
||||
eq(resources.wildcard, true),
|
||||
inArray(resources.fullDomain, wildcardCandidates)
|
||||
inArray(
|
||||
resources.fullDomain,
|
||||
wildcardCandidates
|
||||
)
|
||||
)
|
||||
: sql`false`
|
||||
)
|
||||
@@ -545,10 +554,10 @@ hybridRouter.get(
|
||||
|
||||
if (
|
||||
result &&
|
||||
await checkExitNodeOrg(
|
||||
(await checkExitNodeOrg(
|
||||
remoteExitNode.exitNodeId,
|
||||
result.resources.orgId
|
||||
)
|
||||
))
|
||||
) {
|
||||
// If the exit node is not allowed for the org, return an error
|
||||
return next(
|
||||
@@ -1132,22 +1141,43 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
const roleResourceAccess = await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
eq(roleResources.roleId, roleId)
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
eq(roleResources.roleId, roleId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
.limit(1),
|
||||
db
|
||||
.select({
|
||||
roleId: rolePolicies.roleId,
|
||||
resourcePolicyId: rolePolicies.resourcePolicyId
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(rolePolicies.roleId, roleId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
]);
|
||||
|
||||
const result =
|
||||
roleResourceAccess.length > 0 ? roleResourceAccess[0] : null;
|
||||
const result = direct[0] ?? viaPolicies[0] ?? null;
|
||||
|
||||
return response<typeof roleResources.$inferSelect | null>(res, {
|
||||
data: result,
|
||||
data: result as any,
|
||||
success: true,
|
||||
error: false,
|
||||
message: result
|
||||
@@ -1222,21 +1252,44 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
const roleResourceAccess = await db
|
||||
.select({
|
||||
resourceId: roleResources.resourceId,
|
||||
roleId: roleResources.roleId
|
||||
})
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
);
|
||||
const [direct, viaPolicies] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
resourceId: roleResources.resourceId,
|
||||
roleId: roleResources.roleId
|
||||
})
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
inArray(roleResources.roleId, roleIds)
|
||||
)
|
||||
),
|
||||
roleIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
resourceId: sql<number>`${resourceId}`,
|
||||
roleId: rolePolicies.roleId
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
inArray(rolePolicies.roleId, roleIds)
|
||||
)
|
||||
)
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
const result =
|
||||
roleResourceAccess.length > 0 ? roleResourceAccess : null;
|
||||
const combined = [...direct, ...viaPolicies];
|
||||
const result = combined.length > 0 ? combined : null;
|
||||
|
||||
return response<{ resourceId: number; roleId: number }[] | null>(
|
||||
res,
|
||||
@@ -1397,10 +1450,45 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
const rules = await db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId));
|
||||
const [directRules, policyRules] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId)),
|
||||
db
|
||||
.select({
|
||||
ruleId: resourcePolicyRules.ruleId,
|
||||
resourceId: sql<number>`${resourceId}`,
|
||||
enabled: resourcePolicyRules.enabled,
|
||||
priority: resourcePolicyRules.priority,
|
||||
action: resourcePolicyRules.action,
|
||||
match: resourcePolicyRules.match,
|
||||
value: resourcePolicyRules.value
|
||||
})
|
||||
.from(resourcePolicyRules)
|
||||
.innerJoin(
|
||||
resources,
|
||||
eq(
|
||||
resources.resourcePolicyId,
|
||||
resourcePolicyRules.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
]);
|
||||
|
||||
const maxDirectPriority = directRules.reduce(
|
||||
(max, r) => Math.max(max, r.priority),
|
||||
0
|
||||
);
|
||||
const offsetPolicyRules = policyRules.map((r) => ({
|
||||
...r,
|
||||
priority: maxDirectPriority + r.priority
|
||||
}));
|
||||
|
||||
const rules = [
|
||||
...directRules,
|
||||
...offsetPolicyRules
|
||||
] as (typeof resourceRules.$inferSelect)[];
|
||||
|
||||
// backward compatibility: COUNTRY -> GEOIP
|
||||
// TODO: remove this after a few versions once all exit nodes are updated
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db } from "@server/db";
|
||||
import { clientSitesAssociationsCache, 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";
|
||||
@@ -146,7 +146,11 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
||||
// each unique sourceAddr + the org's CIDR suffix and do a targeted IN query.
|
||||
const ipToClient = new Map<
|
||||
string,
|
||||
{ clientId: number; userId: string | null }
|
||||
{
|
||||
clientId: number;
|
||||
userId: string | null;
|
||||
clientEndpoint: string | null;
|
||||
}
|
||||
>();
|
||||
|
||||
if (cidrSuffix) {
|
||||
@@ -172,9 +176,21 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
userId: clients.userId,
|
||||
subnet: clients.subnet
|
||||
subnet: clients.subnet,
|
||||
clientEndpoint: clientSitesAssociationsCache.endpoint
|
||||
})
|
||||
.from(clients)
|
||||
.leftJoin(
|
||||
// this should be one to one
|
||||
clientSitesAssociationsCache,
|
||||
and(
|
||||
eq(
|
||||
clients.clientId,
|
||||
clientSitesAssociationsCache.clientId
|
||||
),
|
||||
eq(clientSitesAssociationsCache.siteId, newt.siteId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(clients.orgId, orgId),
|
||||
@@ -189,7 +205,8 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
||||
);
|
||||
ipToClient.set(ip, {
|
||||
clientId: c.clientId,
|
||||
userId: c.userId
|
||||
userId: c.userId,
|
||||
clientEndpoint: c.clientEndpoint
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -234,6 +251,7 @@ export const handleConnectionLogMessage: MessageHandler = async (context) => {
|
||||
orgId,
|
||||
siteId: newt.siteId,
|
||||
clientId: clientInfo?.clientId ?? null,
|
||||
clientEndpoint: clientInfo?.clientEndpoint ?? null,
|
||||
userId: clientInfo?.userId ?? null,
|
||||
sourceAddr: session.sourceAddr,
|
||||
destAddr: session.destAddr,
|
||||
|
||||
417
server/private/routers/policy/createResourcePolicy.ts
Normal file
417
server/private/routers/policy/createResourcePolicy.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 { hashPassword } from "@server/auth/password";
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
orgs,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyRules,
|
||||
resourcePolicyWhiteList,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userOrgs,
|
||||
userPolicies,
|
||||
users,
|
||||
type ResourcePolicy
|
||||
} from "@server/db";
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import response from "@server/lib/response";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, inArray, type InferInsertModel } 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 createResourcePolicyParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const ruleSchema = z.strictObject({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
|
||||
type: "string",
|
||||
enum: ["ACCEPT", "DROP", "PASS"],
|
||||
description: "rule action"
|
||||
}),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
||||
type: "string",
|
||||
enum: ["CIDR", "IP", "PATH"],
|
||||
description: "rule match"
|
||||
}),
|
||||
value: z.string().min(1),
|
||||
priority: z.int().openapi({
|
||||
type: "integer",
|
||||
description: "Rule priority"
|
||||
}),
|
||||
enabled: z.boolean().optional()
|
||||
});
|
||||
|
||||
const createResourcePolicyBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
// Access control
|
||||
sso: z.boolean().default(true),
|
||||
skipToIdpId: z
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.nullable()
|
||||
.openapi({ type: "integer" }),
|
||||
roleIds: z
|
||||
.array(z.string().transform(Number).pipe(z.int().positive()))
|
||||
.optional()
|
||||
.default([]),
|
||||
userIds: z.array(z.string()).optional().default([]),
|
||||
// auth methods
|
||||
password: z.string().min(4).max(100).nullable().optional(),
|
||||
pincode: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.or(z.null())
|
||||
.optional(),
|
||||
headerAuth: z
|
||||
.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
// email OTP
|
||||
emailWhitelistEnabled: z.boolean().optional().default(false),
|
||||
emails: z
|
||||
.array(
|
||||
z.email().or(
|
||||
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
.optional()
|
||||
.default([]),
|
||||
// rules
|
||||
applyRules: z.boolean().default(false),
|
||||
rules: z.array(ruleSchema).optional().default([])
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/resource-policy",
|
||||
description: "Create a resource policy.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: createResourcePolicyParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: createResourcePolicyBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function createResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
// Validate request params
|
||||
const parsedParams = createResourcePolicyParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && req.userOrgRoleIds?.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
}
|
||||
|
||||
// get the org
|
||||
const org = await db
|
||||
.select()
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, orgId))
|
||||
.limit(1);
|
||||
|
||||
if (org.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Organization with ID ${orgId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = createResourcePolicyBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
sso,
|
||||
userIds,
|
||||
roleIds,
|
||||
skipToIdpId,
|
||||
applyRules,
|
||||
emailWhitelistEnabled,
|
||||
password,
|
||||
pincode,
|
||||
headerAuth,
|
||||
emails,
|
||||
rules
|
||||
} = parsedBody.data;
|
||||
|
||||
// Check if Identity provider in `skipToIdpId` exists
|
||||
if (skipToIdpId) {
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||
.where(and(eq(idp.idpId, skipToIdpId), eq(idpOrg.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Identity provider not found in this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
const existingRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(inArray(roles.roleId, roleIds)));
|
||||
|
||||
const hasAdminRole = existingRoles.some((role) => role.isAdmin);
|
||||
|
||||
if (hasAdminRole) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Admin role cannot be assigned to resource policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const existingUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
|
||||
.where(
|
||||
and(eq(userOrgs.orgId, orgId), inArray(users.userId, userIds))
|
||||
);
|
||||
|
||||
const niceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
} else if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const policy = await db.transaction(async (trx) => {
|
||||
const [newPolicy] = await trx
|
||||
.insert(resourcePolicies)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
sso,
|
||||
idpId: skipToIdpId,
|
||||
applyRules,
|
||||
emailWhitelistEnabled
|
||||
})
|
||||
.returning();
|
||||
|
||||
const rolesToAdd = [
|
||||
{
|
||||
roleId: adminRole[0].roleId,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}
|
||||
] satisfies InferInsertModel<typeof rolePolicies>[];
|
||||
|
||||
rolesToAdd.push(
|
||||
...existingRoles.map((role) => ({
|
||||
roleId: role.roleId,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}))
|
||||
);
|
||||
|
||||
await trx.insert(rolePolicies).values(rolesToAdd);
|
||||
|
||||
const usersToAdd: InferInsertModel<typeof userPolicies>[] = [];
|
||||
|
||||
if (
|
||||
req.user &&
|
||||
!req.userOrgRoleIds?.includes(adminRole[0].roleId)
|
||||
) {
|
||||
// make sure the user can access the policy
|
||||
usersToAdd.push({
|
||||
userId: req.user?.userId!,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
});
|
||||
}
|
||||
|
||||
usersToAdd.push(
|
||||
...existingUsers.map(({ user }) => ({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}))
|
||||
);
|
||||
|
||||
if (usersToAdd.length > 0) {
|
||||
await trx.insert(userPolicies).values(usersToAdd);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
await trx.insert(resourcePolicyPassword).values({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
passwordHash
|
||||
});
|
||||
}
|
||||
|
||||
if (pincode) {
|
||||
const pincodeHash = await hashPassword(pincode);
|
||||
|
||||
await trx.insert(resourcePolicyPincode).values({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
pincodeHash,
|
||||
digitLength: 6
|
||||
});
|
||||
}
|
||||
|
||||
if (headerAuth) {
|
||||
const headerAuthHash = await hashPassword(
|
||||
Buffer.from(
|
||||
`${headerAuth.user}:${headerAuth.password}`
|
||||
).toString("base64")
|
||||
);
|
||||
|
||||
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
headerAuthHash,
|
||||
extendedCompatibility: headerAuth.extendedCompatibility
|
||||
});
|
||||
}
|
||||
|
||||
if (emailWhitelistEnabled && emails.length > 0) {
|
||||
await trx.insert(resourcePolicyWhiteList).values(
|
||||
emails.map((email) => ({
|
||||
email,
|
||||
resourcePolicyId: newPolicy.resourcePolicyId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (rules.length > 0) {
|
||||
await trx.insert(resourcePolicyRules).values(
|
||||
rules.map((rule) => ({
|
||||
resourcePolicyId: newPolicy.resourcePolicyId,
|
||||
...rule
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return newPolicy;
|
||||
});
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
return response<ResourcePolicy>(res, {
|
||||
data: policy,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "resource policy created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
107
server/private/routers/policy/deleteResourcePolicy.ts
Normal file
107
server/private/routers/policy/deleteResourcePolicy.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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, resourcePolicies, resources } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import z from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
// Define Zod schema for request parameters validation
|
||||
const deleteResourcePolicySchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/resource-policy/{resourcePolicyId}",
|
||||
description: "Delete a resource policy.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: deleteResourcePolicySchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function deleteResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = deleteResourcePolicySchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
if (!existingResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource Policy with ID ${resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const totalAffectedResources = await db.$count(
|
||||
db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourcePolicyId, resourcePolicyId))
|
||||
);
|
||||
|
||||
if (totalAffectedResources > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
`Cannot delete Policy '${existingResource.name}' as it's being used by at least one resource`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// delete policy
|
||||
await db
|
||||
.delete(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource Policy deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
16
server/private/routers/policy/index.ts
Normal file
16
server/private/routers/policy/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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 "./createResourcePolicy";
|
||||
export * from "./listResourcePolicies";
|
||||
export * from "./deleteResourcePolicy";
|
||||
271
server/private/routers/policy/listResourcePolicies.ts
Normal file
271
server/private/routers/policy/listResourcePolicies.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 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,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
rolePolicies,
|
||||
userPolicies
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import type {
|
||||
ListResourcePoliciesResponse,
|
||||
ResourcePolicyWithResources
|
||||
} from "@server/routers/resource/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, asc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
|
||||
const listResourcePoliciesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listResourcePoliciesSchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
}),
|
||||
query: z.string().optional()
|
||||
});
|
||||
|
||||
function queryResourcePoliciesBase() {
|
||||
return db
|
||||
.select({
|
||||
resourcePolicyId: resourcePolicies.resourcePolicyId,
|
||||
name: resourcePolicies.name,
|
||||
niceId: resourcePolicies.niceId,
|
||||
orgId: resourcePolicies.orgId
|
||||
})
|
||||
.from(resourcePolicies);
|
||||
}
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource-policies",
|
||||
description: "List resource policies for an organization.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: z.object({
|
||||
orgId: z.string()
|
||||
}),
|
||||
query: listResourcePoliciesSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function listResourcePolicies(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listResourcePoliciesSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { page, pageSize, query } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listResourcePoliciesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgId =
|
||||
parsedParams.data.orgId ||
|
||||
req.userOrg?.orgId ||
|
||||
req.apiKeyOrg?.orgId;
|
||||
|
||||
if (!orgId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||
);
|
||||
}
|
||||
|
||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have access to this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
let accessibleResourcePolicies: Array<{ resourcePolicyId: number }>;
|
||||
if (req.user) {
|
||||
accessibleResourcePolicies = await db
|
||||
.select({
|
||||
resourcePolicyId: sql<number>`COALESCE(${userPolicies.resourcePolicyId}, ${rolePolicies.resourcePolicyId})`
|
||||
})
|
||||
.from(userPolicies)
|
||||
.fullJoin(
|
||||
rolePolicies,
|
||||
eq(
|
||||
userPolicies.resourcePolicyId,
|
||||
rolePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(userPolicies.userId, req.user!.userId),
|
||||
inArray(rolePolicies.roleId, req.userOrgRoleIds || [])
|
||||
)
|
||||
);
|
||||
} else {
|
||||
accessibleResourcePolicies = await db
|
||||
.select({
|
||||
resourcePolicyId: resourcePolicies.resourcePolicyId
|
||||
})
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.orgId, orgId));
|
||||
}
|
||||
|
||||
const accessibleResourceIds = accessibleResourcePolicies.map(
|
||||
(resource) => resource.resourcePolicyId
|
||||
);
|
||||
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
accessibleResourceIds
|
||||
),
|
||||
eq(resourcePolicies.orgId, orgId),
|
||||
eq(resourcePolicies.scope, "global")
|
||||
)
|
||||
];
|
||||
|
||||
if (query) {
|
||||
conditions.push(
|
||||
or(
|
||||
like(
|
||||
sql`LOWER(${resourcePolicies.name})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
),
|
||||
like(
|
||||
sql`LOWER(${resourcePolicies.niceId})`,
|
||||
"%" + query.toLowerCase() + "%"
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const baseQuery = queryResourcePoliciesBase().where(and(...conditions));
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(baseQuery.as("filtered_policies"));
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
baseQuery
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1))
|
||||
.orderBy(asc(resourcePolicies.resourcePolicyId)),
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const attachedResources =
|
||||
rows.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
name: resources.name,
|
||||
fullDomain: resources.fullDomain,
|
||||
resourcePolicyId: resources.resourcePolicyId
|
||||
})
|
||||
.from(resources)
|
||||
.where(
|
||||
inArray(
|
||||
resources.resourcePolicyId,
|
||||
rows.map((row) => row.resourcePolicyId)
|
||||
)
|
||||
);
|
||||
|
||||
// avoids TS issues with reduce/never[]
|
||||
const map = new Map<number, ResourcePolicyWithResources>();
|
||||
|
||||
for (const row of rows) {
|
||||
let entry = map.get(row.resourcePolicyId);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
...row,
|
||||
resources: []
|
||||
};
|
||||
map.set(row.resourcePolicyId, entry);
|
||||
}
|
||||
|
||||
entry.resources = attachedResources.filter(
|
||||
(r) => r.resourcePolicyId === entry?.resourcePolicyId
|
||||
);
|
||||
}
|
||||
|
||||
const policiesList = Array.from(map.values());
|
||||
|
||||
return response<ListResourcePoliciesResponse>(res, {
|
||||
data: {
|
||||
policies: policiesList,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resources retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { clients, db } from "@server/db";
|
||||
import { clients, db, primaryDb, Client } from "@server/db";
|
||||
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -98,15 +98,6 @@ export async function addUserRole(
|
||||
);
|
||||
}
|
||||
|
||||
if (existingUser[0].isOwner) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Cannot change the role of the owner of the organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const roleExists = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -122,8 +113,12 @@ export async function addUserRole(
|
||||
);
|
||||
}
|
||||
|
||||
let newUserRole: { userId: string; orgId: string; roleId: number } | null =
|
||||
null;
|
||||
let newUserRole: {
|
||||
userId: string;
|
||||
orgId: string;
|
||||
roleId: number;
|
||||
} | null = null;
|
||||
let orgClientsToRebuild: Client[] = [];
|
||||
await db.transaction(async (trx) => {
|
||||
const inserted = await trx
|
||||
.insert(userOrgRoles)
|
||||
@@ -149,11 +144,19 @@ export async function addUserRole(
|
||||
)
|
||||
);
|
||||
|
||||
for (const orgClient of orgClients) {
|
||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
||||
}
|
||||
orgClientsToRebuild = orgClients;
|
||||
});
|
||||
|
||||
for (const orgClient of orgClientsToRebuild) {
|
||||
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: newUserRole ?? { userId, orgId: role.orgId, roleId },
|
||||
success: true,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { db } from "@server/db";
|
||||
import { db, primaryDb, Client } from "@server/db";
|
||||
import { userOrgRoles, userOrgs, roles, clients } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -98,11 +98,11 @@ export async function removeUserRole(
|
||||
);
|
||||
}
|
||||
|
||||
if (existingUser.isOwner) {
|
||||
if (existingUser.isOwner && role.isAdmin === true) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Cannot change the roles of the owner of the organization"
|
||||
"Cannot remove the administrator role from the organization owner"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -129,6 +129,7 @@ export async function removeUserRole(
|
||||
}
|
||||
}
|
||||
|
||||
let orgClientsToRebuild: Client[] = [];
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(userOrgRoles)
|
||||
@@ -150,11 +151,19 @@ export async function removeUserRole(
|
||||
)
|
||||
);
|
||||
|
||||
for (const orgClient of orgClients) {
|
||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
||||
}
|
||||
orgClientsToRebuild = orgClients;
|
||||
});
|
||||
|
||||
for (const orgClient of orgClientsToRebuild) {
|
||||
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: { userId, orgId: role.orgId, roleId },
|
||||
success: true,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { clients, db } from "@server/db";
|
||||
import { clients, db, primaryDb, Client } from "@server/db";
|
||||
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -87,17 +87,8 @@ export async function setUserOrgRoles(
|
||||
);
|
||||
}
|
||||
|
||||
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 })
|
||||
.select({ roleId: roles.roleId, isAdmin: roles.isAdmin })
|
||||
.from(roles)
|
||||
.where(
|
||||
and(
|
||||
@@ -115,6 +106,19 @@ export async function setUserOrgRoles(
|
||||
);
|
||||
}
|
||||
|
||||
if (existingUser.isOwner) {
|
||||
const hasAdminRole = orgRoles.some((r) => r.isAdmin === true);
|
||||
if (!hasAdminRole) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"The organization owner must retain an administrator role"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let orgClientsToRebuild: Client[] = [];
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(userOrgRoles)
|
||||
@@ -142,11 +146,19 @@ export async function setUserOrgRoles(
|
||||
and(eq(clients.userId, userId), eq(clients.orgId, orgId))
|
||||
);
|
||||
|
||||
for (const orgClient of orgClients) {
|
||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
||||
}
|
||||
orgClientsToRebuild = orgClients;
|
||||
});
|
||||
|
||||
for (const orgClient of orgClientsToRebuild) {
|
||||
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: { userId, orgId, roleIds: uniqueRoleIds },
|
||||
success: true,
|
||||
|
||||
@@ -100,6 +100,7 @@ export type QueryConnectionAuditLogResponse = {
|
||||
orgId: string | null;
|
||||
siteId: number | null;
|
||||
clientId: number | null;
|
||||
clientEndpoint: string | null;
|
||||
userId: string | null;
|
||||
sourceAddr: string;
|
||||
destAddr: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, orgs, userOrgs, users } from "@server/db";
|
||||
import { db, orgs, userOrgs, users, primaryDb } from "@server/db";
|
||||
import { eq, and, inArray, not } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -218,13 +218,18 @@ export async function deleteMyAccount(
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.delete(users).where(eq(users.userId, userId));
|
||||
await calculateUserClientsForOrgs(userId, trx);
|
||||
// loop through the other orgs and decrement the count
|
||||
for (const userOrg of otherOrgsTheUserWasIn) {
|
||||
await usageService.add(userOrg.orgId, FeatureId.USERS, -1, trx);
|
||||
}
|
||||
});
|
||||
|
||||
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||
logger.error(
|
||||
`Failed to calculate user clients after deleting account for user ${userId}: ${e}`
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await invalidateSession(session.sessionId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -671,7 +671,8 @@ export async function verifyResourceSession(
|
||||
resourceData.org
|
||||
);
|
||||
|
||||
localCache.set(userAccessCacheKey, allowedUserData, 5);
|
||||
// this is query intensive so let it cache a little longer
|
||||
localCache.set(userAccessCacheKey, allowedUserData, 12);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1003,11 +1004,7 @@ async function checkRules(
|
||||
isIpInCidr(clientIp, rule.value)
|
||||
) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "IP" &&
|
||||
clientIp == rule.value
|
||||
) {
|
||||
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
path &&
|
||||
@@ -1015,10 +1012,7 @@ async function checkRules(
|
||||
isPathAllowed(rule.value, path)
|
||||
) {
|
||||
return rule.action as any;
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "COUNTRY"
|
||||
) {
|
||||
} else if (clientIp && rule.match == "COUNTRY") {
|
||||
// COUNTRY=ALL should not affect local/private/CGNAT addresses.
|
||||
if (
|
||||
rule.value.toUpperCase() === "ALL" &&
|
||||
@@ -1030,10 +1024,7 @@ async function checkRules(
|
||||
if (await isIpInGeoIP(ipCC, rule.value)) {
|
||||
return rule.action as any;
|
||||
}
|
||||
} else if (
|
||||
clientIp &&
|
||||
rule.match == "ASN"
|
||||
) {
|
||||
} else if (clientIp && rule.match == "ASN") {
|
||||
// ASN=ALL/AS0 should not affect local/private/CGNAT addresses.
|
||||
if (
|
||||
(rule.value.toUpperCase() === "ALL" ||
|
||||
@@ -1272,11 +1263,15 @@ export async function isIpInRegion(
|
||||
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})`);
|
||||
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})`);
|
||||
logger.debug(
|
||||
`Country ${upperCode} is not in region ${region.id} (${region.name})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1284,10 +1279,14 @@ export async function isIpInRegion(
|
||||
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})`);
|
||||
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})`);
|
||||
logger.debug(
|
||||
`Country ${upperCode} is not in region ${subregion.id} (${subregion.name})`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, primaryDb } from "@server/db";
|
||||
import {
|
||||
roles,
|
||||
Client,
|
||||
@@ -92,7 +92,10 @@ export async function createClient(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
if (
|
||||
req.user &&
|
||||
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
@@ -198,7 +201,10 @@ export async function createClient(
|
||||
|
||||
if (!randomExitNode) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`)
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,10 +262,18 @@ export async function createClient(
|
||||
clientId: newClient.clientId,
|
||||
dateCreated: moment().toISOString()
|
||||
});
|
||||
|
||||
await rebuildClientAssociationsFromClient(newClient, trx);
|
||||
});
|
||||
|
||||
if (newClient) {
|
||||
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild client associations after creating client: ${e}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return response<CreateClientResponse>(res, {
|
||||
data: newClient,
|
||||
success: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, primaryDb } from "@server/db";
|
||||
import {
|
||||
roles,
|
||||
Client,
|
||||
@@ -237,10 +237,18 @@ export async function createUserClient(
|
||||
userId,
|
||||
clientId: newClient.clientId
|
||||
});
|
||||
|
||||
await rebuildClientAssociationsFromClient(newClient, trx);
|
||||
});
|
||||
|
||||
if (newClient) {
|
||||
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild client associations after creating user client: ${e}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return response<CreateClientAndOlmResponse>(res, {
|
||||
data: newClient,
|
||||
success: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, olms } from "@server/db";
|
||||
import { db, olms, primaryDb, Client, Olm } from "@server/db";
|
||||
import { clients, clientSitesAssociationsCache } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -71,14 +71,17 @@ export async function deleteClient(
|
||||
);
|
||||
}
|
||||
|
||||
let deletedClient: Client | undefined;
|
||||
let olm: Olm | undefined;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// Then delete the client itself
|
||||
const [deletedClient] = await trx
|
||||
[deletedClient] = await trx
|
||||
.delete(clients)
|
||||
.where(eq(clients.clientId, clientId))
|
||||
.returning();
|
||||
|
||||
const [olm] = await trx
|
||||
[olm] = await trx
|
||||
.select()
|
||||
.from(olms)
|
||||
.where(eq(olms.clientId, clientId))
|
||||
@@ -88,14 +91,29 @@ export async function deleteClient(
|
||||
if (!client.userId && client.olmId) {
|
||||
await trx.delete(olms).where(eq(olms.olmId, client.olmId));
|
||||
}
|
||||
|
||||
await rebuildClientAssociationsFromClient(deletedClient, trx);
|
||||
|
||||
if (olm) {
|
||||
await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion
|
||||
}
|
||||
});
|
||||
|
||||
if (deletedClient) {
|
||||
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild client associations after deleting client ${clientId}: ${e}`
|
||||
);
|
||||
}
|
||||
);
|
||||
if (olm) {
|
||||
sendTerminateClient(
|
||||
deletedClient.clientId,
|
||||
OlmErrorCodes.TERMINATED_DELETED,
|
||||
olm.olmId
|
||||
).catch((e) => {
|
||||
logger.error(
|
||||
`Failed to send terminate message for client ${deletedClient?.clientId} after deleting client ${clientId}: ${e}`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import config from "@server/lib/config";
|
||||
import * as site from "./site";
|
||||
import * as org from "./org";
|
||||
import * as resource from "./resource";
|
||||
import * as policy from "./policy";
|
||||
import * as domain from "./domain";
|
||||
import * as target from "./target";
|
||||
import * as user from "./user";
|
||||
@@ -42,7 +43,8 @@ import {
|
||||
verifyUserIsOrgOwner,
|
||||
verifySiteResourceAccess,
|
||||
verifyOlmAccess,
|
||||
verifyLimits
|
||||
verifyLimits,
|
||||
verifyResourcePolicyAccess
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import rateLimit, { ipKeyGenerator } from "express-rate-limit";
|
||||
@@ -103,7 +105,6 @@ authenticated.put(
|
||||
site.createSite
|
||||
);
|
||||
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/sites",
|
||||
verifyOrgAccess,
|
||||
@@ -540,6 +541,7 @@ authenticated.get(
|
||||
verifyUserHasAction(ActionsEnum.getResource),
|
||||
resource.getResource
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyResourceAccess,
|
||||
@@ -646,6 +648,29 @@ authenticated.post(
|
||||
logActionAudit(ActionsEnum.updateRole),
|
||||
role.updateRole
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/resource-policy/:niceId",
|
||||
verifyOrgAccess,
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||
policy.getResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/policies",
|
||||
verifyResourceAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||
resource.getResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateResourcePolicy),
|
||||
policy.updateResourcePolicy
|
||||
);
|
||||
|
||||
// authenticated.get(
|
||||
// "/role/:roleId",
|
||||
// verifyRoleAccess,
|
||||
@@ -697,6 +722,59 @@ authenticated.post(
|
||||
resource.setResourceUsers
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/access-control",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
||||
policy.setResourcePolicyAccessControl
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/password",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyPassword),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPassword),
|
||||
policy.setResourcePolicyPassword
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/pincode",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyPincode),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPincode),
|
||||
policy.setResourcePolicyPincode
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/header-auth",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
policy.setResourcePolicyHeaderAuth
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/whitelist",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyWhitelist),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
|
||||
policy.setResourcePolicyWhitelist
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/rules",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyRules),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyRules),
|
||||
policy.setResourcePolicyRules
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/resource/:resourceId/password`,
|
||||
verifyResourceAccess,
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as site from "./site";
|
||||
import * as org from "./org";
|
||||
import * as blueprints from "./blueprints";
|
||||
import * as resource from "./resource";
|
||||
import * as policy from "./policy";
|
||||
import * as domain from "./domain";
|
||||
import * as target from "./target";
|
||||
import * as user from "./user";
|
||||
@@ -29,7 +30,9 @@ import {
|
||||
verifyApiKeySiteResourceAccess,
|
||||
verifyApiKeySetResourceClients,
|
||||
verifyLimits,
|
||||
verifyApiKeyDomainAccess
|
||||
verifyApiKeyDomainAccess,
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyUserHasAction
|
||||
} from "@server/middlewares";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { Router } from "express";
|
||||
@@ -459,6 +462,20 @@ authenticated.get(
|
||||
resource.getResource
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
|
||||
policy.getResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource/:resourceId/policies",
|
||||
verifyApiKeyResourceAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.getResourcePolicy),
|
||||
resource.getResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId",
|
||||
verifyApiKeyResourceAccess,
|
||||
@@ -468,6 +485,13 @@ authenticated.post(
|
||||
resource.updateResource
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateResourcePolicy),
|
||||
policy.updateResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/resource/:resourceId",
|
||||
verifyApiKeyResourceAccess,
|
||||
@@ -619,6 +643,63 @@ authenticated.post(
|
||||
resource.setResourceUsers
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/access-control",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyApiKeyRoleAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyUsers),
|
||||
verifyUserHasAction(ActionsEnum.setResourcePolicyRoles),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyUsers),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyRoles),
|
||||
policy.setResourcePolicyAccessControl
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/password",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPassword),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPassword),
|
||||
policy.setResourcePolicyPassword
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/pincode",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyPincode),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyPincode),
|
||||
policy.setResourcePolicyPincode
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/header-auth",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyHeaderAuth),
|
||||
policy.setResourcePolicyHeaderAuth
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/whitelist",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyWhitelist),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyWhitelist),
|
||||
policy.setResourcePolicyWhitelist
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId/rules",
|
||||
verifyApiKeyResourcePolicyAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.setResourcePolicyRules),
|
||||
logActionAudit(ActionsEnum.setResourcePolicyRules),
|
||||
policy.setResourcePolicyRules
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/resource/:resourceId/roles/add",
|
||||
verifyApiKeyResourceAccess,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { db, olms } from "@server/db";
|
||||
import { db, olms, primaryDb } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { z } from "zod";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -81,16 +81,19 @@ export async function createUserOlm(
|
||||
|
||||
const secretHash = await hashPassword(secret);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.insert(olms).values({
|
||||
olmId: olmId,
|
||||
userId,
|
||||
name,
|
||||
secretHash,
|
||||
dateCreated: moment().toISOString()
|
||||
});
|
||||
await db.insert(olms).values({
|
||||
olmId: olmId,
|
||||
userId,
|
||||
name,
|
||||
secretHash,
|
||||
dateCreated: moment().toISOString()
|
||||
});
|
||||
|
||||
await calculateUserClientsForOrgs(userId, trx);
|
||||
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||
console.error(
|
||||
"Error calculating user clients after creating olm:",
|
||||
e
|
||||
);
|
||||
});
|
||||
|
||||
return response<CreateOlmResponse>(res, {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { Client, db } from "@server/db";
|
||||
import { Client, db, Olm, primaryDb } from "@server/db";
|
||||
import { olms, clients, clientSitesAssociationsCache } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -49,6 +49,7 @@ export async function deleteUserOlm(
|
||||
|
||||
const { olmId } = parsedParams.data;
|
||||
|
||||
let deletedClient: Client | undefined;
|
||||
// Delete associated clients and the OLM in a transaction
|
||||
await db.transaction(async (trx) => {
|
||||
// Find all clients associated with this OLM
|
||||
@@ -57,7 +58,6 @@ export async function deleteUserOlm(
|
||||
.from(clients)
|
||||
.where(eq(clients.olmId, olmId));
|
||||
|
||||
let deletedClient: Client | null = null;
|
||||
// Delete all associated clients
|
||||
if (associatedClients.length > 0) {
|
||||
[deletedClient] = await trx
|
||||
@@ -67,23 +67,28 @@ export async function deleteUserOlm(
|
||||
}
|
||||
|
||||
// Finally, delete the OLM itself
|
||||
const [olm] = await trx
|
||||
.delete(olms)
|
||||
.where(eq(olms.olmId, olmId))
|
||||
.returning();
|
||||
|
||||
if (deletedClient) {
|
||||
await rebuildClientAssociationsFromClient(deletedClient, trx);
|
||||
if (olm) {
|
||||
await sendTerminateClient(
|
||||
deletedClient.clientId,
|
||||
OlmErrorCodes.TERMINATED_DELETED,
|
||||
olm.olmId
|
||||
); // the olmId needs to be provided because it cant look it up after deletion
|
||||
}
|
||||
}
|
||||
await trx.delete(olms).where(eq(olms.olmId, olmId)).returning();
|
||||
});
|
||||
|
||||
if (deletedClient) {
|
||||
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}`
|
||||
);
|
||||
}
|
||||
);
|
||||
sendTerminateClient(
|
||||
deletedClient.clientId,
|
||||
OlmErrorCodes.TERMINATED_DELETED,
|
||||
olmId
|
||||
).catch((e) => {
|
||||
logger.error(
|
||||
`Failed to send terminate message for client ${deletedClient?.clientId} after deleting OLM ${olmId}: ${e}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
||||
@@ -22,14 +22,14 @@ import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
logger.info("Handling register olm message!");
|
||||
logger.info("[handleOlmRegisterMessage] Handling register olm message");
|
||||
const { message, client: c, sendToClient } = context;
|
||||
const olm = c as Olm;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (!olm) {
|
||||
logger.warn("Olm not found");
|
||||
logger.warn("[handleOlmRegisterMessage] Olm not found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,16 +46,19 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
} = message.data;
|
||||
|
||||
if (!olm.clientId) {
|
||||
logger.warn("Olm client ID not found");
|
||||
logger.warn("[handleOlmRegisterMessage] Olm client ID not found");
|
||||
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Handling fingerprint insertion for olm register...", {
|
||||
olmId: olm.olmId,
|
||||
fingerprint,
|
||||
postures
|
||||
});
|
||||
logger.debug(
|
||||
"[handleOlmRegisterMessage] Handling fingerprint insertion for olm register...",
|
||||
{
|
||||
olmId: olm.olmId,
|
||||
fingerprint,
|
||||
postures
|
||||
}
|
||||
);
|
||||
|
||||
const isUserDevice = olm.userId !== null && olm.userId !== undefined;
|
||||
|
||||
@@ -85,14 +88,17 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
.limit(1);
|
||||
|
||||
if (!client) {
|
||||
logger.warn("Client ID not found");
|
||||
logger.warn("[handleOlmRegisterMessage] Client not found", {
|
||||
clientId: olm.clientId
|
||||
});
|
||||
sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (client.blocked) {
|
||||
logger.debug(
|
||||
`Client ${client.clientId} is blocked. Ignoring register.`
|
||||
`[handleOlmRegisterMessage] Client ${client.clientId} is blocked. Ignoring register.`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId);
|
||||
return;
|
||||
@@ -100,7 +106,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
|
||||
if (client.approvalState == "pending") {
|
||||
logger.debug(
|
||||
`Client ${client.clientId} approval is pending. Ignoring register.`
|
||||
`[handleOlmRegisterMessage] Client ${client.clientId} approval is pending. Ignoring register.`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId);
|
||||
return;
|
||||
@@ -128,14 +135,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
.limit(1);
|
||||
|
||||
if (!org) {
|
||||
logger.warn("Org not found");
|
||||
logger.warn("[handleOlmRegisterMessage] Org not found", {
|
||||
orgId: client.orgId
|
||||
});
|
||||
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (orgId) {
|
||||
if (!olm.userId) {
|
||||
logger.warn("Olm has no user ID");
|
||||
logger.warn("[handleOlmRegisterMessage] Olm has no user ID", {
|
||||
orgId: client.orgId
|
||||
});
|
||||
sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId);
|
||||
return;
|
||||
}
|
||||
@@ -143,12 +154,18 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
const { session: userSession, user } =
|
||||
await validateSessionToken(userToken);
|
||||
if (!userSession || !user) {
|
||||
logger.warn("Invalid user session for olm register");
|
||||
logger.warn(
|
||||
"[handleOlmRegisterMessage] Invalid user session for olm register",
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId);
|
||||
return;
|
||||
}
|
||||
if (user.userId !== olm.userId) {
|
||||
logger.warn("User ID mismatch for olm register");
|
||||
logger.warn(
|
||||
"[handleOlmRegisterMessage] User ID mismatch for olm register",
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId);
|
||||
return;
|
||||
}
|
||||
@@ -163,11 +180,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
sessionId // this is the user token passed in the message
|
||||
});
|
||||
|
||||
logger.debug("Policy check result:", policyCheck);
|
||||
logger.debug("[handleOlmRegisterMessage] Policy check result", {
|
||||
orgId: client.orgId,
|
||||
policyCheck
|
||||
});
|
||||
|
||||
if (policyCheck?.error) {
|
||||
logger.error(
|
||||
`Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`
|
||||
`[handleOlmRegisterMessage] Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
||||
return;
|
||||
@@ -175,7 +196,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
|
||||
if (policyCheck.policies?.passwordAge?.compliant === false) {
|
||||
logger.warn(
|
||||
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
|
||||
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant password age for org ${orgId}`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
sendOlmError(
|
||||
OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED,
|
||||
@@ -186,7 +208,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
policyCheck.policies?.maxSessionLength?.compliant === false
|
||||
) {
|
||||
logger.warn(
|
||||
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}`
|
||||
`[handleOlmRegisterMessage] Olm user ${olm.userId} has non-compliant session length for org ${orgId}`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
sendOlmError(
|
||||
OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED,
|
||||
@@ -195,7 +218,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
} else if (policyCheck.policies?.requiredTwoFactor === false) {
|
||||
logger.warn(
|
||||
`Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`
|
||||
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
sendOlmError(
|
||||
OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED,
|
||||
@@ -204,7 +228,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
} else if (!policyCheck.allowed) {
|
||||
logger.warn(
|
||||
`Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`
|
||||
`[handleOlmRegisterMessage] Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
|
||||
return;
|
||||
@@ -226,29 +251,39 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
|
||||
|
||||
// Prepare an array to store site configurations
|
||||
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
|
||||
logger.debug(
|
||||
`[handleOlmRegisterMessage] Found ${sitesCount} sites for client ${client.clientId}`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
|
||||
let jitMode = false;
|
||||
if (sitesCount > 250 && build == "saas") {
|
||||
// THIS IS THE MAX ON THE BUSINESS TIER
|
||||
// we have too many sites
|
||||
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
|
||||
logger.info("Too many sites (%d), dropping into JIT mode", sitesCount);
|
||||
logger.info(
|
||||
`[handleOlmRegisterMessage] Too many sites (${sitesCount}), dropping into JIT mode`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
jitMode = true;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`
|
||||
`[handleOlmRegisterMessage] Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
|
||||
if (!publicKey) {
|
||||
logger.warn("Public key not provided");
|
||||
logger.warn("[handleOlmRegisterMessage] Public key not provided", {
|
||||
orgId: client.orgId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (client.pubKey !== publicKey || client.archived) {
|
||||
logger.info(
|
||||
"Public key mismatch. Updating public key and clearing session info..."
|
||||
"[handleOlmRegisterMessage] Public key mismatch. Updating public key and clearing session info...",
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
// Update the client's public key
|
||||
await db
|
||||
@@ -274,12 +309,13 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
||||
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
|
||||
logger.warn(
|
||||
`Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`
|
||||
`[handleOlmRegisterMessage] Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?`,
|
||||
{ orgId: client.orgId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE: its important that the client here is the old client and the public key is the new key
|
||||
// NOTE: its important that the client here is the old client and the public key is the new key
|
||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(
|
||||
client,
|
||||
publicKey,
|
||||
|
||||
231
server/routers/policy/getResourcePolicy.ts
Normal file
231
server/routers/policy/getResourcePolicy.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
resourcePolicyRules,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resourcePolicyWhiteList,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userPolicies,
|
||||
users
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq, isNull, not, or, type SQL } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import z from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const getResourcePolicySchema = z
|
||||
.strictObject({
|
||||
niceId: z.string(),
|
||||
orgId: z.string()
|
||||
})
|
||||
.or(
|
||||
z.strictObject({
|
||||
resourcePolicyId: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.openapi({
|
||||
type: "integer",
|
||||
description: "Resource policy ID"
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
export async function queryResourcePolicy(
|
||||
params: z.infer<typeof getResourcePolicySchema>
|
||||
) {
|
||||
const conditions: SQL<unknown>[] = [];
|
||||
if ("resourcePolicyId" in params) {
|
||||
conditions.push(
|
||||
eq(resourcePolicies.resourcePolicyId, params.resourcePolicyId)
|
||||
);
|
||||
} else {
|
||||
conditions.push(
|
||||
eq(resourcePolicies.niceId, params.niceId),
|
||||
eq(resourcePolicies.orgId, params.orgId)
|
||||
);
|
||||
}
|
||||
|
||||
const [res] = await db
|
||||
.select({
|
||||
resourcePolicyId: resourcePolicies.resourcePolicyId,
|
||||
sso: resourcePolicies.sso,
|
||||
applyRules: resourcePolicies.applyRules,
|
||||
emailWhitelistEnabled: resourcePolicies.emailWhitelistEnabled,
|
||||
idpId: resourcePolicies.idpId,
|
||||
niceId: resourcePolicies.niceId,
|
||||
name: resourcePolicies.name,
|
||||
passwordId: resourcePolicyPassword.passwordId,
|
||||
pincodeId: resourcePolicyPincode.pincodeId,
|
||||
headerAuth: {
|
||||
id: resourcePolicyHeaderAuth.headerAuthId,
|
||||
extendedCompability:
|
||||
resourcePolicyHeaderAuth.extendedCompatibility
|
||||
}
|
||||
})
|
||||
.from(resourcePolicies)
|
||||
.leftJoin(
|
||||
resourcePolicyPassword,
|
||||
eq(
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPincode,
|
||||
eq(
|
||||
resourcePolicyPincode.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyHeaderAuth,
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.limit(1);
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
const policyUsers = await db
|
||||
.select({
|
||||
userId: userPolicies.userId,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
username: users.username,
|
||||
type: users.type,
|
||||
idpName: idp.name
|
||||
})
|
||||
.from(userPolicies)
|
||||
.innerJoin(users, eq(userPolicies.userId, users.userId))
|
||||
.leftJoin(idp, eq(idp.idpId, users.idpId))
|
||||
.where(eq(userPolicies.resourcePolicyId, res.resourcePolicyId));
|
||||
|
||||
const policyRoles = await db
|
||||
.select({
|
||||
roleId: rolePolicies.roleId,
|
||||
name: roles.name
|
||||
})
|
||||
.from(rolePolicies)
|
||||
.innerJoin(
|
||||
roles,
|
||||
and(
|
||||
eq(rolePolicies.roleId, roles.roleId),
|
||||
or(isNull(roles.isAdmin), not(roles.isAdmin))
|
||||
)
|
||||
)
|
||||
.where(eq(rolePolicies.resourcePolicyId, res.resourcePolicyId));
|
||||
|
||||
const policyEmailWhiteList = await db
|
||||
.select({
|
||||
whiteListId: resourcePolicyWhiteList.whitelistId,
|
||||
email: resourcePolicyWhiteList.email
|
||||
})
|
||||
.from(resourcePolicyWhiteList)
|
||||
.where(
|
||||
eq(resourcePolicyWhiteList.resourcePolicyId, res.resourcePolicyId)
|
||||
);
|
||||
|
||||
const policyRules = await db
|
||||
.select({
|
||||
ruleId: resourcePolicyRules.ruleId,
|
||||
enabled: resourcePolicyRules.enabled,
|
||||
priority: resourcePolicyRules.priority,
|
||||
action: resourcePolicyRules.action,
|
||||
match: resourcePolicyRules.match,
|
||||
value: resourcePolicyRules.value
|
||||
})
|
||||
.from(resourcePolicyRules)
|
||||
.where(eq(resourcePolicyRules.resourcePolicyId, res.resourcePolicyId));
|
||||
|
||||
return {
|
||||
...res,
|
||||
roles: policyRoles,
|
||||
users: policyUsers,
|
||||
emailWhiteList: policyEmailWhiteList,
|
||||
rules: policyRules
|
||||
};
|
||||
}
|
||||
|
||||
export type GetResourcePolicyResponse = NonNullable<
|
||||
Awaited<ReturnType<typeof queryResourcePolicy>>
|
||||
>;
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/resource-policy/{niceId}",
|
||||
description:
|
||||
"Get a resource policy by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: z.object({
|
||||
orgId: z.string(),
|
||||
niceId: z.string()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/resource-policy/{resourcePolicyId}",
|
||||
description: "Get a resource policy by its resourcePolicyId.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: z.object({
|
||||
resourcePolicyId: z.number()
|
||||
})
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getResourcePolicySchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const policy = await queryResourcePolicy(parsedParams.data);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
return response<GetResourcePolicyResponse>(res, {
|
||||
data: policy,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource Policy retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
8
server/routers/policy/index.ts
Normal file
8
server/routers/policy/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from "./getResourcePolicy";
|
||||
export * from "./updateResourcePolicy";
|
||||
export * from "./setResourcePolicyAccessControl";
|
||||
export * from "./setResourcePolicyPassword";
|
||||
export * from "./setResourcePolicyPincode";
|
||||
export * from "./setResourcePolicyHeaderAuth";
|
||||
export * from "./setResourcePolicyWhitelist";
|
||||
export * from "./setResourcePolicyRules";
|
||||
237
server/routers/policy/setResourcePolicyAccessControl.ts
Normal file
237
server/routers/policy/setResourcePolicyAccessControl.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
idp,
|
||||
idpOrg,
|
||||
resourcePolicies,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { userPolicies } 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 { and, eq, inArray, ne } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyAcccessControlBodySchema = z.strictObject({
|
||||
sso: z.boolean(),
|
||||
userIds: z.array(z.string()),
|
||||
roleIds: z.array(z.int().positive()).openapi({
|
||||
type: "array"
|
||||
}),
|
||||
skipToIdpId: z.int().positive().optional().nullable().openapi({
|
||||
type: "integer",
|
||||
description: "Page number to retrieve"
|
||||
})
|
||||
});
|
||||
|
||||
const setResourcePolicyAccessControlParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource-policy/{resourceId}/access-control",
|
||||
description:
|
||||
"Set access control users for a resource policy, including SSO, users, roles, Identity provider.",
|
||||
tags: [OpenAPITags.Policy, OpenAPITags.User],
|
||||
request: {
|
||||
params: setResourcePolicyAccessControlParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyAcccessControlBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyAccessControl(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = setResourcePolicyAcccessControlBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userIds, roleIds, sso, skipToIdpId: idpId } = parsedBody.data;
|
||||
|
||||
const parsedParams =
|
||||
setResourcePolicyAccessControlParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Resource policy not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if Identity provider in `skipToIdpId` exists
|
||||
if (idpId) {
|
||||
const [provider] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||
.where(
|
||||
and(eq(idp.idpId, idpId), eq(idpOrg.orgId, policy.orgId))
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!provider) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Identity provider not found in this organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any of the roleIds are admin roles
|
||||
const rolesToCheck = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(
|
||||
and(
|
||||
inArray(roles.roleId, roleIds),
|
||||
eq(roles.orgId, policy.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
const hasAdminRole = rolesToCheck.some((role) => role.isAdmin);
|
||||
|
||||
if (hasAdminRole) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Admin role cannot be assigned to resources"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get all admin role IDs for this org to exclude from deletion
|
||||
const adminRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, policy.orgId)));
|
||||
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
||||
|
||||
const existingUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(userOrgs.userId, users.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, policy.orgId),
|
||||
inArray(users.userId, userIds)
|
||||
)
|
||||
);
|
||||
|
||||
const existingRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(
|
||||
and(
|
||||
eq(roles.orgId, policy.orgId),
|
||||
inArray(roles.roleId, roleIds)
|
||||
)
|
||||
);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// Update SSO status
|
||||
await trx
|
||||
.update(resourcePolicies)
|
||||
.set({
|
||||
sso,
|
||||
idpId
|
||||
})
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
// Update roles
|
||||
if (adminRoleIds.length > 0) {
|
||||
await trx.delete(rolePolicies).where(
|
||||
and(
|
||||
eq(rolePolicies.resourcePolicyId, resourcePolicyId),
|
||||
ne(rolePolicies.roleId, adminRoleIds[0]) // delete all but the admin role
|
||||
)
|
||||
);
|
||||
} else {
|
||||
await trx
|
||||
.delete(rolePolicies)
|
||||
.where(eq(rolePolicies.resourcePolicyId, resourcePolicyId));
|
||||
}
|
||||
|
||||
const rolesToAdd = existingRoles.map(({ roleId }) => ({
|
||||
roleId,
|
||||
resourcePolicyId
|
||||
}));
|
||||
|
||||
if (rolesToAdd.length > 0) {
|
||||
await trx.insert(rolePolicies).values(rolesToAdd);
|
||||
}
|
||||
|
||||
// Update users
|
||||
await trx
|
||||
.delete(userPolicies)
|
||||
.where(eq(userPolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
const usersToAdd = existingUsers.map(({ user }) => ({
|
||||
userId: user.userId,
|
||||
resourcePolicyId: resourcePolicyId
|
||||
}));
|
||||
|
||||
if (usersToAdd.length > 0) {
|
||||
await trx.insert(userPolicies).values(usersToAdd);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy succesfully updated",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
117
server/routers/policy/setResourcePolicyHeaderAuth.ts
Normal file
117
server/routers/policy/setResourcePolicyHeaderAuth.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicyHeaderAuth } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyHeaderAuthParamsSchema = z.object({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const setResourcePolicyHeaderAuthBodySchema = z.strictObject({
|
||||
headerAuth: z
|
||||
.object({
|
||||
user: z.string().min(4).max(100),
|
||||
password: z.string().min(4).max(100),
|
||||
extendedCompatibility: z.boolean()
|
||||
})
|
||||
.nullable()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/header-auth",
|
||||
description:
|
||||
"Set or update the header authentication for a resource policy. If user and password is not provided, it will remove the header authentication.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyHeaderAuthParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyHeaderAuthBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyHeaderAuth(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyHeaderAuthParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyHeaderAuthBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { headerAuth } = parsedBody.data;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourcePolicyHeaderAuth)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicyId
|
||||
)
|
||||
);
|
||||
|
||||
if (headerAuth !== null) {
|
||||
const headerAuthHash = await hashPassword(
|
||||
Buffer.from(
|
||||
`${headerAuth.user}:${headerAuth.password}`
|
||||
).toString("base64")
|
||||
);
|
||||
|
||||
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||
resourcePolicyId,
|
||||
headerAuthHash,
|
||||
extendedCompatibility: headerAuth.extendedCompatibility
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Header Authentication set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
106
server/routers/policy/setResourcePolicyPassword.ts
Normal file
106
server/routers/policy/setResourcePolicyPassword.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicyPassword } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyPasswordParamsSchema = z.object({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const setResourcePolicyPasswordBodySchema = z.strictObject({
|
||||
password: z.string().min(4).max(100).nullable()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/password",
|
||||
description:
|
||||
"Set the password for a resource policy. Setting the password to null will remove it.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyPasswordParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyPasswordBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyPassword(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyPasswordParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyPasswordBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { password } = parsedBody.data;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourcePolicyPassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicyId
|
||||
)
|
||||
);
|
||||
|
||||
if (password) {
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
await trx
|
||||
.insert(resourcePolicyPassword)
|
||||
.values({ resourcePolicyId, passwordHash });
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy password set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
106
server/routers/policy/setResourcePolicyPincode.ts
Normal file
106
server/routers/policy/setResourcePolicyPincode.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resourcePolicyPincode } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response } from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { hashPassword } from "@server/auth/password";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyPincodeParamsSchema = z.object({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const setResourcePolicyPincodeBodySchema = z.strictObject({
|
||||
pincode: z
|
||||
.string()
|
||||
.regex(/^\d{6}$/)
|
||||
.or(z.null())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/pincode",
|
||||
description:
|
||||
"Set the PIN code for a resource policy. Setting the PIN code to null will remove it.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyPincodeParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyPincodeBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyPincode(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyPincodeParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyPincodeBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { pincode } = parsedBody.data;
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(resourcePolicyPincode)
|
||||
.where(
|
||||
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
|
||||
);
|
||||
|
||||
if (pincode) {
|
||||
const pincodeHash = await hashPassword(pincode);
|
||||
|
||||
await trx
|
||||
.insert(resourcePolicyPincode)
|
||||
.values({ resourcePolicyId, pincodeHash, digitLength: 6 });
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy PIN code set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
167
server/routers/policy/setResourcePolicyRules.ts
Normal file
167
server/routers/policy/setResourcePolicyRules.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicyRules, resourcePolicies } from "@server/db";
|
||||
import { 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 {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
} from "@server/lib/validators";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const ruleSchema = z.strictObject({
|
||||
action: z.enum(["ACCEPT", "DROP", "PASS"]).openapi({
|
||||
type: "string",
|
||||
enum: ["ACCEPT", "DROP", "PASS"],
|
||||
description: "rule action"
|
||||
}),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
||||
type: "string",
|
||||
enum: ["CIDR", "IP", "PATH"],
|
||||
description: "rule match"
|
||||
}),
|
||||
value: z.string().min(1),
|
||||
priority: z.int().openapi({
|
||||
type: "integer",
|
||||
description: "Rule priority"
|
||||
}),
|
||||
enabled: z.boolean().optional()
|
||||
});
|
||||
|
||||
const setResourcePolicyRulesBodySchema = z.strictObject({
|
||||
applyRules: z.boolean(),
|
||||
rules: z.array(ruleSchema)
|
||||
});
|
||||
|
||||
const setResourcePolicyRulesParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/rules",
|
||||
description:
|
||||
"Set all rules for a resource policy at once. This will replace all existing rules.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyRulesParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyRulesBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyRules(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = setResourcePolicyRulesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = setResourcePolicyRulesBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { applyRules, rules } = parsedBody.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
} else if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(resourcePolicies)
|
||||
.set({ applyRules })
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
await trx
|
||||
.delete(resourcePolicyRules)
|
||||
.where(
|
||||
eq(resourcePolicyRules.resourcePolicyId, resourcePolicyId)
|
||||
);
|
||||
|
||||
if (rules.length > 0) {
|
||||
await trx.insert(resourcePolicyRules).values(
|
||||
rules.map((rule) => ({
|
||||
resourcePolicyId,
|
||||
...rule
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy rules set successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
132
server/routers/policy/setResourcePolicyWhitelist.ts
Normal file
132
server/routers/policy/setResourcePolicyWhitelist.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, resourcePolicies, resourcePolicyWhiteList } 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 { and, eq } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const setResourcePolicyWhitelistBodySchema = z.strictObject({
|
||||
emailWhitelistEnabled: z.boolean(),
|
||||
emails: z
|
||||
.array(
|
||||
z.email().or(
|
||||
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||
})
|
||||
)
|
||||
)
|
||||
.max(50)
|
||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||
});
|
||||
|
||||
const setResourcePolicyWhitelistParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}/whitelist",
|
||||
description:
|
||||
"Set email whitelist for a resource policy. This will replace all existing emails.",
|
||||
tags: [OpenAPITags.Policy],
|
||||
request: {
|
||||
params: setResourcePolicyWhitelistParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: setResourcePolicyWhitelistBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function setResourcePolicyWhitelist(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = setResourcePolicyWhitelistBodySchema.safeParse(
|
||||
req.body
|
||||
);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = setResourcePolicyWhitelistParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const { emailWhitelistEnabled, emails } = parsedBody.data;
|
||||
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
if (!policy) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource policy not found")
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.update(resourcePolicies)
|
||||
.set({ emailWhitelistEnabled })
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId));
|
||||
|
||||
// delete all whitelist emails
|
||||
await trx
|
||||
.delete(resourcePolicyWhiteList)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicyWhiteList.resourcePolicyId,
|
||||
resourcePolicyId
|
||||
)
|
||||
);
|
||||
|
||||
if (emailWhitelistEnabled && emails.length > 0) {
|
||||
await trx.insert(resourcePolicyWhiteList).values(
|
||||
emails.map((email) => ({
|
||||
email,
|
||||
resourcePolicyId
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Whitelist set for resource policy successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
157
server/routers/policy/updateResourcePolicy.ts
Normal file
157
server/routers/policy/updateResourcePolicy.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import z from "zod";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { db, orgs, resourcePolicies, type ResourcePolicy } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import response from "@server/lib/response";
|
||||
|
||||
const updateResourcePolicyParamsSchema = z.strictObject({
|
||||
resourcePolicyId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
const updateResourcePolicyBodySchema = z.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
niceId: z.string().min(1).max(255).optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/resource-policy/{resourcePolicyId}",
|
||||
description: "Update a resource policy.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: updateResourcePolicyParamsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: updateResourcePolicyBodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function updateResourcePolicy(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const parsedParams = updateResourcePolicyParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (req.user && req.userOrgRoleIds?.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
}
|
||||
|
||||
const { resourcePolicyId } = parsedParams.data;
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.leftJoin(orgs, eq(resourcePolicies.orgId, orgs.orgId));
|
||||
|
||||
const policy = result?.resourcePolicies;
|
||||
const org = result?.orgs;
|
||||
|
||||
if (!policy || !org) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource Policy with ID ${resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBody = updateResourcePolicyBodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingPolicy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.niceId, updateData.niceId),
|
||||
eq(resourcePolicies.orgId, policy.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
existingPolicy &&
|
||||
existingPolicy.resourcePolicyId !== policy.resourcePolicyId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`A resource policy with niceId "${updateData.niceId}" already exists`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedPolicy = await db.transaction(async (trx) => {
|
||||
const [updated] = await trx
|
||||
.update(resourcePolicies)
|
||||
.set({
|
||||
...updateData
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
policy.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (!updatedPolicy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to update policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response<ResourcePolicy>(res, {
|
||||
data: updatedPolicy,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policy updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import { build } from "@server/build";
|
||||
import {
|
||||
domains,
|
||||
orgDomains,
|
||||
db,
|
||||
loginPage,
|
||||
orgs,
|
||||
Resource,
|
||||
resources,
|
||||
resourcePolicies,
|
||||
roleResources,
|
||||
rolePolicies,
|
||||
roles,
|
||||
userResources
|
||||
userPolicies,
|
||||
userResources,
|
||||
domainNamespaces
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -20,13 +24,18 @@ import logger from "@server/logger";
|
||||
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
import config from "@server/lib/config";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { build } from "@server/build";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { getUniqueResourceName } from "@server/db/names";
|
||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
||||
import {
|
||||
validateAndConstructDomain,
|
||||
checkWildcardDomainConflict
|
||||
} from "@server/lib/domainUtils";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import {
|
||||
getUniqueResourceName,
|
||||
getUniqueResourcePolicyName
|
||||
} from "@server/db/names";
|
||||
|
||||
const createResourceParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -311,8 +320,46 @@ async function createHttpResource(
|
||||
let resource: Resource | undefined;
|
||||
|
||||
const niceId = await getUniqueResourceName(orgId);
|
||||
const policyNiceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const adminRole = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
const [defaultPolicy] = await trx
|
||||
.insert(resourcePolicies)
|
||||
.values({
|
||||
niceId: policyNiceId,
|
||||
orgId,
|
||||
name: `default policy for ${niceId}`,
|
||||
sso: true,
|
||||
scope: "resource"
|
||||
})
|
||||
.returning();
|
||||
|
||||
// make this policy visible by the admin role
|
||||
await trx.insert(rolePolicies).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
|
||||
// make this policy visible by the current user
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: req.user?.userId!,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
}
|
||||
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
@@ -328,22 +375,11 @@ async function createHttpResource(
|
||||
stickySession: stickySession,
|
||||
postAuthPath: postAuthPath,
|
||||
wildcard,
|
||||
health: "unknown"
|
||||
health: "unknown",
|
||||
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, `Admin role not found`)
|
||||
);
|
||||
}
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
@@ -369,7 +405,7 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
if (build != "oss") {
|
||||
if (build !== "oss") {
|
||||
await createCertificate(domainId, fullDomain, db);
|
||||
}
|
||||
|
||||
@@ -410,22 +446,10 @@ async function createRawResource(
|
||||
let resource: Resource | undefined;
|
||||
|
||||
const niceId = await getUniqueResourceName(orgId);
|
||||
const policyNiceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
http,
|
||||
protocol,
|
||||
proxyPort
|
||||
// enableProxy
|
||||
})
|
||||
.returning();
|
||||
|
||||
const adminRole = await db
|
||||
const adminRole = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
@@ -437,6 +461,44 @@ async function createRawResource(
|
||||
);
|
||||
}
|
||||
|
||||
const [defaultPolicy] = await trx
|
||||
.insert(resourcePolicies)
|
||||
.values({
|
||||
niceId: policyNiceId,
|
||||
orgId,
|
||||
name: `default policy for ${niceId}`,
|
||||
sso: true,
|
||||
scope: "resource"
|
||||
})
|
||||
.returning();
|
||||
|
||||
// make this policy visible by the admin role
|
||||
await trx.insert(rolePolicies).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
|
||||
// make this policy visible by the current user
|
||||
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
|
||||
await trx.insert(userPolicies).values({
|
||||
userId: req.user?.userId!,
|
||||
resourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
});
|
||||
}
|
||||
|
||||
const newResource = await trx
|
||||
.insert(resources)
|
||||
.values({
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
http,
|
||||
protocol,
|
||||
proxyPort,
|
||||
defaultResourcePolicyId: defaultPolicy.resourcePolicyId
|
||||
})
|
||||
.returning();
|
||||
|
||||
await trx.insert(roleResources).values({
|
||||
roleId: adminRole[0].roleId,
|
||||
resourceId: newResource[0].resourceId
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, targetHealthCheck } from "@server/db";
|
||||
import { newts, resources, sites, targets } from "@server/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
newts,
|
||||
resourcePolicies,
|
||||
resources,
|
||||
sites,
|
||||
targetHealthCheck,
|
||||
targets
|
||||
} 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 { addPeer } from "../gerbil/peers";
|
||||
import { removeTargets } from "../newt/targets";
|
||||
import { getAllowedIps } from "../target/helpers";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { removeTargets } from "../newt/targets";
|
||||
|
||||
// Define Zod schema for request parameters validation
|
||||
const deleteResourceSchema = z.strictObject({
|
||||
@@ -113,6 +118,18 @@ export async function deleteResource(
|
||||
}
|
||||
}
|
||||
|
||||
// Also delete default resource policy
|
||||
if (deletedResource.defaultResourcePolicyId) {
|
||||
await db
|
||||
.delete(resourcePolicies)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
deletedResource.defaultResourcePolicyId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resources
|
||||
} from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -60,64 +60,53 @@ export async function getResourceAuthInfo(
|
||||
|
||||
const isGuidInteger = /^\d+$/.test(resourceGuid);
|
||||
|
||||
const buildQuery = (whereClause: ReturnType<typeof eq>) =>
|
||||
db
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
resourcePolicies,
|
||||
or(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resources.resourcePolicyId
|
||||
),
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resources.defaultResourcePolicyId
|
||||
)
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPincode,
|
||||
eq(
|
||||
resourcePolicyPincode.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPassword,
|
||||
eq(
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyHeaderAuth,
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.where(whereClause)
|
||||
.limit(1);
|
||||
|
||||
const [result] =
|
||||
isGuidInteger && build === "saas"
|
||||
? await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
resourceHeaderAuth,
|
||||
eq(
|
||||
resourceHeaderAuth.resourceId,
|
||||
resources.resourceId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
resources.resourceId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceId, Number(resourceGuid)))
|
||||
.limit(1)
|
||||
: await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
resourceHeaderAuth,
|
||||
eq(
|
||||
resourceHeaderAuth.resourceId,
|
||||
resources.resourceId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
resources.resourceId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.resourceGuid, resourceGuid))
|
||||
.limit(1);
|
||||
? await buildQuery(
|
||||
eq(resources.resourceId, Number(resourceGuid))
|
||||
)
|
||||
: await buildQuery(eq(resources.resourceGuid, resourceGuid));
|
||||
|
||||
const resource = result?.resources;
|
||||
if (!resource) {
|
||||
@@ -126,11 +115,10 @@ export async function getResourceAuthInfo(
|
||||
);
|
||||
}
|
||||
|
||||
const pincode = result?.resourcePincode;
|
||||
const password = result?.resourcePassword;
|
||||
const headerAuth = result?.resourceHeaderAuth;
|
||||
const headerAuthExtendedCompatibility =
|
||||
result?.resourceHeaderAuthExtendedCompatibility;
|
||||
const policy = result?.resourcePolicies;
|
||||
const pincode = result?.resourcePolicyPincode;
|
||||
const password = result?.resourcePolicyPassword;
|
||||
const headerAuth = result?.resourcePolicyHeaderAuth;
|
||||
|
||||
const url = resource.fullDomain
|
||||
? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||
@@ -146,13 +134,13 @@ export async function getResourceAuthInfo(
|
||||
pincode: pincode !== null,
|
||||
headerAuth: headerAuth !== null,
|
||||
headerAuthExtendedCompatibility:
|
||||
headerAuthExtendedCompatibility !== null,
|
||||
sso: resource.sso,
|
||||
headerAuth?.extendedCompatibility ?? false,
|
||||
sso: policy?.sso ?? false,
|
||||
blockAccess: resource.blockAccess,
|
||||
url: url ?? "",
|
||||
wildcard: resource.wildcard ?? false,
|
||||
fullDomain: resource.fullDomain,
|
||||
whitelist: resource.emailWhitelistEnabled,
|
||||
whitelist: policy?.emailWhitelistEnabled ?? false,
|
||||
skipToIdpId: resource.skipToIdpId,
|
||||
orgId: resource.orgId,
|
||||
postAuthPath: resource.postAuthPath ?? null
|
||||
|
||||
109
server/routers/resource/getResourcePolicies.ts
Normal file
109
server/routers/resource/getResourcePolicies.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { db, resources } from "@server/db";
|
||||
import {
|
||||
queryResourcePolicy,
|
||||
type GetResourcePolicyResponse
|
||||
} from "@server/routers/policy/getResourcePolicy";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import z from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const getResourcePoliciesParamsSchema = z.strictObject({
|
||||
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||
});
|
||||
|
||||
export type GetResourcePoliciesResponse = {
|
||||
defaultPolicy: GetResourcePolicyResponse;
|
||||
sharedPolicy: GetResourcePolicyResponse | null;
|
||||
};
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/resource/{resourceId}/policies",
|
||||
description: "Get the inline and shared policies associated with a resource.",
|
||||
tags: [OpenAPITags.PublicResource, OpenAPITags.Policy],
|
||||
request: {
|
||||
params: getResourcePoliciesParamsSchema
|
||||
},
|
||||
responses: {}
|
||||
});
|
||||
|
||||
export async function getResourcePolicies(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = getResourcePoliciesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
const [resource] = await db
|
||||
.select({
|
||||
defaultResourcePolicyId: resources.defaultResourcePolicyId,
|
||||
resourcePolicyId: resources.resourcePolicyId
|
||||
})
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||
);
|
||||
}
|
||||
|
||||
if (!resource.defaultResourcePolicyId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Resource has no default policy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [defaultPolicy, sharedPolicy] = await Promise.all([
|
||||
queryResourcePolicy({
|
||||
resourcePolicyId: resource.defaultResourcePolicyId
|
||||
}),
|
||||
resource.resourcePolicyId
|
||||
? queryResourcePolicy({
|
||||
resourcePolicyId: resource.resourcePolicyId
|
||||
})
|
||||
: null
|
||||
]);
|
||||
|
||||
return response<GetResourcePoliciesResponse>(res, {
|
||||
data: {
|
||||
defaultPolicy:
|
||||
// the policy will always be non nullable
|
||||
defaultPolicy as unknown as GetResourcePolicyResponse,
|
||||
sharedPolicy
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource policies retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,3 +33,4 @@ export * from "./removeUserFromResource";
|
||||
export * from "./listAllResourceNames";
|
||||
export * from "./removeEmailFromResourceWhitelist";
|
||||
export * from "./getStatusHistory";
|
||||
export * from "./getResourcePolicies";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
db,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourcePolicies,
|
||||
resourcePolicyHeaderAuth,
|
||||
resourcePolicyPassword,
|
||||
resourcePolicyPincode,
|
||||
resources,
|
||||
roleResources,
|
||||
sites,
|
||||
@@ -163,10 +163,10 @@ function queryResourcesBase() {
|
||||
name: resources.name,
|
||||
ssl: resources.ssl,
|
||||
fullDomain: resources.fullDomain,
|
||||
passwordId: resourcePassword.passwordId,
|
||||
sso: resources.sso,
|
||||
pincodeId: resourcePincode.pincodeId,
|
||||
whitelist: resources.emailWhitelistEnabled,
|
||||
passwordId: resourcePolicyPassword.passwordId,
|
||||
sso: resourcePolicies.sso,
|
||||
pincodeId: resourcePolicyPincode.pincodeId,
|
||||
whitelist: resourcePolicies.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
protocol: resources.protocol,
|
||||
proxyPort: resources.proxyPort,
|
||||
@@ -174,29 +174,45 @@ function queryResourcesBase() {
|
||||
domainId: resources.domainId,
|
||||
niceId: resources.niceId,
|
||||
wildcard: resources.wildcard,
|
||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||
headerAuthExtendedCompatibilityId:
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
||||
health: resources.health
|
||||
health: resources.health,
|
||||
headerAuthId: resourcePolicyHeaderAuth.headerAuthId,
|
||||
headerAuthExtendedCompatibility:
|
||||
resourcePolicyHeaderAuth.extendedCompatibility
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
resourcePassword,
|
||||
eq(resourcePassword.resourceId, resources.resourceId)
|
||||
resourcePolicies,
|
||||
or(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resources.resourcePolicyId
|
||||
),
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resources.defaultResourcePolicyId
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
.leftJoin(
|
||||
resourcePincode,
|
||||
eq(resourcePincode.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourceHeaderAuth,
|
||||
eq(resourceHeaderAuth.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePolicyPassword,
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
resources.resourceId
|
||||
resourcePolicyPassword.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyPincode,
|
||||
eq(
|
||||
resourcePolicyPincode.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(
|
||||
resourcePolicyHeaderAuth,
|
||||
eq(
|
||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||
resourcePolicies.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||
@@ -206,10 +222,10 @@ function queryResourcesBase() {
|
||||
)
|
||||
.groupBy(
|
||||
resources.resourceId,
|
||||
resourcePassword.passwordId,
|
||||
resourcePincode.pincodeId,
|
||||
resourceHeaderAuth.headerAuthId,
|
||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||
resourcePolicies.resourcePolicyId,
|
||||
resourcePolicyPassword.passwordId,
|
||||
resourcePolicyPincode.pincodeId,
|
||||
resourcePolicyHeaderAuth.headerAuthId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -355,21 +371,21 @@ export async function listResources(
|
||||
case "protected":
|
||||
conditions.push(
|
||||
or(
|
||||
eq(resources.sso, true),
|
||||
eq(resources.emailWhitelistEnabled, true),
|
||||
not(isNull(resourceHeaderAuth.headerAuthId)),
|
||||
not(isNull(resourcePincode.pincodeId)),
|
||||
not(isNull(resourcePassword.passwordId))
|
||||
eq(resourcePolicies.sso, true),
|
||||
eq(resourcePolicies.emailWhitelistEnabled, true),
|
||||
not(isNull(resourcePolicyHeaderAuth.headerAuthId)),
|
||||
not(isNull(resourcePolicyPincode.pincodeId)),
|
||||
not(isNull(resourcePolicyPassword.passwordId))
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "not_protected":
|
||||
conditions.push(
|
||||
not(eq(resources.sso, true)),
|
||||
not(eq(resources.emailWhitelistEnabled, true)),
|
||||
isNull(resourceHeaderAuth.headerAuthId),
|
||||
isNull(resourcePincode.pincodeId),
|
||||
isNull(resourcePassword.passwordId)
|
||||
not(eq(resourcePolicies.sso, true)),
|
||||
not(eq(resourcePolicies.emailWhitelistEnabled, true)),
|
||||
isNull(resourcePolicyHeaderAuth.headerAuthId),
|
||||
isNull(resourcePolicyPincode.pincodeId),
|
||||
isNull(resourcePolicyPassword.passwordId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -446,9 +462,9 @@ export async function listResources(
|
||||
ssl: row.ssl,
|
||||
fullDomain: row.fullDomain,
|
||||
passwordId: row.passwordId,
|
||||
sso: row.sso,
|
||||
sso: row.sso ?? false,
|
||||
pincodeId: row.pincodeId,
|
||||
whitelist: row.whitelist,
|
||||
whitelist: row.whitelist ?? false,
|
||||
http: row.http,
|
||||
protocol: row.protocol,
|
||||
proxyPort: row.proxyPort,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { Resource, ResourcePolicy } from "@server/db";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
|
||||
export type GetMaintenanceInfoResponse = {
|
||||
resourceId: number;
|
||||
name: string;
|
||||
@@ -8,3 +11,19 @@ export type GetMaintenanceInfoResponse = {
|
||||
maintenanceMessage: string | null;
|
||||
maintenanceEstimatedTime: string | null;
|
||||
};
|
||||
|
||||
export type AttachedResource = Pick<
|
||||
Resource,
|
||||
"resourceId" | "name" | "fullDomain"
|
||||
>;
|
||||
|
||||
export type ResourcePolicyWithResources = Pick<
|
||||
ResourcePolicy,
|
||||
"resourcePolicyId" | "niceId" | "name" | "orgId"
|
||||
> & {
|
||||
resources: Array<AttachedResource>;
|
||||
};
|
||||
|
||||
export type ListResourcePoliciesResponse = PaginatedResponse<{
|
||||
policies: Array<ResourcePolicyWithResources>;
|
||||
}>;
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import {
|
||||
db,
|
||||
domainNamespaces,
|
||||
loginPage,
|
||||
resourceHeaderAuth,
|
||||
resourceHeaderAuthExtendedCompatibility,
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourceRules,
|
||||
resourceWhitelist
|
||||
} from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
Org,
|
||||
orgDomains,
|
||||
orgs,
|
||||
Resource,
|
||||
resourcePolicies,
|
||||
resources
|
||||
} from "@server/db";
|
||||
import { eq, and, ne } from "drizzle-orm";
|
||||
@@ -24,7 +35,10 @@ import {
|
||||
import { registry } from "@server/openApi";
|
||||
import { OpenAPITags } from "@server/openApi";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
|
||||
import {
|
||||
validateAndConstructDomain,
|
||||
checkWildcardDomainConflict
|
||||
} from "@server/lib/domainUtils";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
@@ -68,7 +82,8 @@ const updateHttpResourceBodySchema = z
|
||||
maintenanceTitle: z.string().max(255).nullable().optional(),
|
||||
maintenanceMessage: z.string().max(2000).nullable().optional(),
|
||||
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
|
||||
postAuthPath: z.string().nullable().optional()
|
||||
postAuthPath: z.string().nullable().optional(),
|
||||
resourcePolicyId: z.number().nullable().optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
@@ -165,7 +180,8 @@ const updateRawResourceBodySchema = z
|
||||
stickySession: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
proxyProtocol: z.boolean().optional(),
|
||||
proxyProtocolVersion: z.int().min(1).optional()
|
||||
proxyProtocolVersion: z.int().min(1).optional(),
|
||||
resourcePolicyId: z.number().nullable().optional()
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
@@ -301,6 +317,42 @@ async function updateHttpResource(
|
||||
|
||||
const updateData = parsedBody.data;
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
resource.orgId,
|
||||
tierMatrix.wildcardSubdomain
|
||||
);
|
||||
|
||||
if (updateData.resourcePolicyId != null) {
|
||||
if (!isLicensed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Resource policies are not supported on your current plan. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existingPolicy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
updateData.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingPolicy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${updateData.resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
@@ -326,10 +378,6 @@ async function updateHttpResource(
|
||||
|
||||
// Wildcard subdomains are a paid feature
|
||||
if (updateData.subdomain && updateData.subdomain.includes("*")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
resource.orgId,
|
||||
tierMatrix.wildcardSubdomain
|
||||
);
|
||||
if (!isLicensed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -474,10 +522,6 @@ async function updateHttpResource(
|
||||
headers = null;
|
||||
}
|
||||
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
resource.orgId,
|
||||
tierMatrix.maintencePage
|
||||
);
|
||||
if (!isLicensed) {
|
||||
updateData.maintenanceModeEnabled = undefined;
|
||||
updateData.maintenanceModeType = undefined;
|
||||
@@ -535,38 +579,122 @@ async function updateRawResource(
|
||||
}
|
||||
|
||||
const updateData = parsedBody.data;
|
||||
let updatedResource: Resource | null = null;
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.niceId, updateData.niceId),
|
||||
eq(resources.orgId, resource.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
existingResource &&
|
||||
existingResource.resourceId !== resource.resourceId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`A resource with niceId "${updateData.niceId}" already exists`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedResource = await db
|
||||
.update(resources)
|
||||
.set(updateData)
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resource.resourceId))
|
||||
.returning();
|
||||
.limit(1);
|
||||
|
||||
if (updatedResource.length === 0) {
|
||||
await db.transaction(async (trx) => {
|
||||
if (updateData.resourcePolicyId != null) {
|
||||
const [existingPolicy] = await trx
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
updateData.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingPolicy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${updateData.resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// we are in an inline policy and we need to clear out the old tables
|
||||
await Promise.all([
|
||||
trx
|
||||
.delete(resourcePassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePassword.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourcePincode)
|
||||
.where(
|
||||
eq(
|
||||
resourcePincode.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceHeaderAuth)
|
||||
.where(
|
||||
eq(
|
||||
resourceHeaderAuth.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceHeaderAuthExtendedCompatibility)
|
||||
.where(
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceWhitelist)
|
||||
.where(
|
||||
eq(
|
||||
resourceWhitelist.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
|
||||
trx
|
||||
.delete(resourceRules)
|
||||
.where(
|
||||
eq(
|
||||
resourceRules.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingResourceConflict] = await trx
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.niceId, updateData.niceId),
|
||||
eq(resources.orgId, resource.orgId)
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
existingResourceConflict &&
|
||||
existingResourceConflict.resourceId !== resource.resourceId
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
`A resource with niceId "${updateData.niceId}" already exists`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
[updatedResource] = await trx
|
||||
.update(resources)
|
||||
.set(updateData)
|
||||
.where(eq(resources.resourceId, resource.resourceId))
|
||||
.returning();
|
||||
});
|
||||
|
||||
if (!updatedResource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
@@ -576,7 +704,7 @@ async function updateRawResource(
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: updatedResource[0],
|
||||
data: updatedResource,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Non-http Resource updated successfully",
|
||||
|
||||
@@ -135,7 +135,7 @@ const listSitesSchema = z.object({
|
||||
page: z.coerce
|
||||
.number<string>() // for prettier formatting
|
||||
.int()
|
||||
.min(0)
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
clients,
|
||||
clientSiteResources,
|
||||
siteResources,
|
||||
apiKeyOrg
|
||||
apiKeyOrg,
|
||||
primaryDb
|
||||
} from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -220,8 +221,12 @@ export async function batchAddClientToSiteResources(
|
||||
siteResourceId: siteResource.siteResourceId
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await rebuildClientAssociationsFromClient(client, trx);
|
||||
rebuildClientAssociationsFromClient(client, primaryDb).catch((e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild client associations after batch adding site resources for client ${clientId}: ${e}`
|
||||
);
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
SiteResource,
|
||||
siteResources,
|
||||
sites,
|
||||
userSiteResources
|
||||
userSiteResources,
|
||||
primaryDb
|
||||
} from "@server/db";
|
||||
import { getUniqueSiteResourceName } from "@server/db/names";
|
||||
import {
|
||||
@@ -519,12 +520,10 @@ export async function createSiteResource(
|
||||
// own transaction so it always executes on the primary — avoiding any
|
||||
// replica-lag issues while still allowing the HTTP response to return
|
||||
// early.
|
||||
db.transaction(async (trx) => {
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
newSiteResource!,
|
||||
trx
|
||||
);
|
||||
}).catch((err) => {
|
||||
rebuildClientAssociationsFromSiteResource(
|
||||
newSiteResource!,
|
||||
primaryDb
|
||||
).catch((err) => {
|
||||
logger.error(
|
||||
`Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`,
|
||||
err
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, newts, sites } from "@server/db";
|
||||
import { db, newts, primaryDb, sites } from "@server/db";
|
||||
import { siteResources } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -73,12 +73,10 @@ export async function deleteSiteResource(
|
||||
// own transaction so it always executes on the primary — avoiding any
|
||||
// replica-lag issues while still allowing the HTTP response to return
|
||||
// early.
|
||||
db.transaction(async (trx) => {
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
removedSiteResource,
|
||||
trx
|
||||
);
|
||||
}).catch((err) => {
|
||||
rebuildClientAssociationsFromSiteResource(
|
||||
removedSiteResource,
|
||||
primaryDb
|
||||
).catch((err) => {
|
||||
logger.error(
|
||||
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
|
||||
err
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, orgs } from "@server/db";
|
||||
import { roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
|
||||
import { db, orgs, primaryDb } 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";
|
||||
@@ -146,9 +152,7 @@ export async function acceptInvite(
|
||||
.from(userInviteRoles)
|
||||
.where(eq(userInviteRoles.inviteId, inviteId));
|
||||
|
||||
const inviteRoleIds = [
|
||||
...new Set(inviteRoleRows.map((r) => r.roleId))
|
||||
];
|
||||
const inviteRoleIds = [...new Set(inviteRoleRows.map((r) => r.roleId))];
|
||||
if (inviteRoleIds.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -193,13 +197,19 @@ export async function acceptInvite(
|
||||
.delete(userInvites)
|
||||
.where(eq(userInvites.inviteId, inviteId));
|
||||
|
||||
await calculateUserClientsForOrgs(existingUser[0].userId, trx);
|
||||
|
||||
logger.debug(
|
||||
`User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}`
|
||||
);
|
||||
});
|
||||
|
||||
calculateUserClientsForOrgs(existingUser[0].userId, primaryDb).catch(
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to calculate user clients after accepting invite for user ${existingUser[0].userId}: ${e}`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return response<AcceptInviteResponse>(res, {
|
||||
data: { accepted: true, orgId: existingInvite.orgId },
|
||||
success: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import { clients, db } from "@server/db";
|
||||
import { clients, db, primaryDb, Client } from "@server/db";
|
||||
import { userOrgRoles, userOrgs, roles } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -88,11 +88,11 @@ export async function addUserRoleLegacy(
|
||||
);
|
||||
}
|
||||
|
||||
if (existingUser.isOwner) {
|
||||
if (existingUser.isOwner && role.isAdmin !== true) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Cannot change the role of the owner of the organization"
|
||||
"The organization owner must retain an administrator role"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,8 @@ export async function addUserRoleLegacy(
|
||||
);
|
||||
}
|
||||
|
||||
let orgClientsToRebuild: Client[] = [];
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx
|
||||
.delete(userOrgRoles)
|
||||
@@ -138,11 +140,19 @@ export async function addUserRoleLegacy(
|
||||
)
|
||||
);
|
||||
|
||||
for (const orgClient of orgClients) {
|
||||
await rebuildClientAssociationsFromClient(orgClient, trx);
|
||||
}
|
||||
orgClientsToRebuild = orgClients;
|
||||
});
|
||||
|
||||
for (const orgClient of orgClientsToRebuild) {
|
||||
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: { ...existingUser, roleId },
|
||||
success: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, primaryDb } from "@server/db";
|
||||
import { users } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -53,8 +53,12 @@ export async function adminRemoveUser(
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.delete(users).where(eq(users.userId, userId));
|
||||
});
|
||||
|
||||
await calculateUserClientsForOrgs(userId, trx);
|
||||
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||
logger.error(
|
||||
`Failed to calculate user clients after removing user ${userId}: ${e}`
|
||||
);
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 } from "@server/db";
|
||||
import { db, orgs, primaryDb } 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";
|
||||
@@ -34,8 +34,7 @@ const bodySchema = z
|
||||
roleId: z.number().int().positive().optional()
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||
(d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||
{ message: "roleIds or roleId is required", path: ["roleIds"] }
|
||||
)
|
||||
.transform((data) => ({
|
||||
@@ -100,8 +99,14 @@ export async function createOrgUser(
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const { username, email, name, type, idpId, roleIds: uniqueRoleIds } =
|
||||
parsedBody.data;
|
||||
const {
|
||||
username,
|
||||
email,
|
||||
name,
|
||||
type,
|
||||
idpId,
|
||||
roleIds: uniqueRoleIds
|
||||
} = parsedBody.data;
|
||||
|
||||
if (build == "saas") {
|
||||
const usage = await usageService.getUsage(orgId, FeatureId.USERS);
|
||||
@@ -232,6 +237,7 @@ export async function createOrgUser(
|
||||
);
|
||||
}
|
||||
|
||||
let userIdForClients: string | undefined;
|
||||
await db.transaction(async (trx) => {
|
||||
const [existingUser] = await trx
|
||||
.select()
|
||||
@@ -270,7 +276,7 @@ export async function createOrgUser(
|
||||
{
|
||||
orgId,
|
||||
userId: existingUser.userId,
|
||||
autoProvisioned: false,
|
||||
autoProvisioned: false
|
||||
},
|
||||
uniqueRoleIds,
|
||||
trx
|
||||
@@ -292,20 +298,30 @@ export async function createOrgUser(
|
||||
})
|
||||
.returning();
|
||||
|
||||
await assignUserToOrg(
|
||||
org,
|
||||
{
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
autoProvisioned: false,
|
||||
},
|
||||
uniqueRoleIds,
|
||||
trx
|
||||
);
|
||||
await assignUserToOrg(
|
||||
org,
|
||||
{
|
||||
orgId,
|
||||
userId: newUser.userId,
|
||||
autoProvisioned: false
|
||||
},
|
||||
uniqueRoleIds,
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
await calculateUserClientsForOrgs(userId, trx);
|
||||
userIdForClients = userId;
|
||||
});
|
||||
|
||||
if (userIdForClients) {
|
||||
calculateUserClientsForOrgs(userIdForClients, primaryDb).catch(
|
||||
(e) => {
|
||||
logger.error(
|
||||
`Failed to calculate user clients after creating org user: ${e}`
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "User type is required")
|
||||
|
||||
@@ -47,10 +47,7 @@ export async function queryUser(orgId: string, userId: string) {
|
||||
.from(userOrgRoles)
|
||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId))
|
||||
);
|
||||
|
||||
const isAdmin = roleRows.some((r) => r.isAdmin);
|
||||
@@ -61,7 +58,8 @@ export async function queryUser(orgId: string, userId: string) {
|
||||
roleIds: roleRows.map((r) => r.roleId),
|
||||
roles: roleRows.map((r) => ({
|
||||
roleId: r.roleId,
|
||||
name: r.roleName ?? ""
|
||||
name: r.roleName ?? "",
|
||||
isAdmin: r.isAdmin === true
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -146,7 +144,7 @@ export async function getOrgUser(
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User does not have permission perform this action"
|
||||
"User does not have permission to get organization user details"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
siteResources,
|
||||
sites,
|
||||
UserOrg,
|
||||
userSiteResources
|
||||
userSiteResources,
|
||||
primaryDb
|
||||
} from "@server/db";
|
||||
import { userOrgs, userResources, users, userSites } from "@server/db";
|
||||
import { and, count, eq, exists, inArray } from "drizzle-orm";
|
||||
@@ -91,25 +92,12 @@ export async function removeUserOrg(
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await removeUserFromOrg(org, userId, trx);
|
||||
});
|
||||
|
||||
// if (build === "saas") {
|
||||
// const [rootUser] = await trx
|
||||
// .select()
|
||||
// .from(users)
|
||||
// .where(eq(users.userId, userId));
|
||||
//
|
||||
// const [leftInOrgs] = await trx
|
||||
// .select({ count: count() })
|
||||
// .from(userOrgs)
|
||||
// .where(eq(userOrgs.userId, userId));
|
||||
//
|
||||
// // if the user is not an internal user and does not belong to any org, delete the entire user
|
||||
// if (rootUser?.type !== UserType.Internal && !leftInOrgs.count) {
|
||||
// await trx.delete(users).where(eq(users.userId, userId));
|
||||
// }
|
||||
// }
|
||||
|
||||
await calculateUserClientsForOrgs(userId, trx);
|
||||
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||
logger.error(
|
||||
`Failed to calculate user clients after removing user ${userId} from org ${orgId}: ${e}`
|
||||
);
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -24,6 +24,7 @@ import m15 from "./scriptsPg/1.16.0";
|
||||
import m16 from "./scriptsPg/1.17.0";
|
||||
import m17 from "./scriptsPg/1.18.0";
|
||||
import m18 from "./scriptsPg/1.18.3";
|
||||
import m19 from "./scriptsPg/1.18.4";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -47,7 +48,8 @@ const migrations = [
|
||||
{ version: "1.16.0", run: m15 },
|
||||
{ version: "1.17.0", run: m16 },
|
||||
{ version: "1.18.0", run: m17 },
|
||||
{ version: "1.18.3", run: m18 }
|
||||
{ version: "1.18.3", run: m18 },
|
||||
{ version: "1.18.4", run: m19 }
|
||||
// Add new migrations here as they are created
|
||||
] as {
|
||||
version: string;
|
||||
|
||||
@@ -42,6 +42,7 @@ import m36 from "./scriptsSqlite/1.16.0";
|
||||
import m37 from "./scriptsSqlite/1.17.0";
|
||||
import m38 from "./scriptsSqlite/1.18.0";
|
||||
import m39 from "./scriptsSqlite/1.18.3";
|
||||
import m40 from "./scriptsSqlite/1.18.4";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -81,7 +82,8 @@ const migrations = [
|
||||
{ version: "1.16.0", run: m36 },
|
||||
{ version: "1.17.0", run: m37 },
|
||||
{ version: "1.18.0", run: m38 },
|
||||
{ version: "1.18.3", run: m39 }
|
||||
{ version: "1.18.3", run: m39 },
|
||||
{ version: "1.18.4", run: m40 }
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export default async function migration() {
|
||||
await db.execute(sql`BEGIN`);
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "trialNotifications" (
|
||||
CREATE TABLE IF NOT EXISTS "trialNotifications" (
|
||||
"notificationId" serial PRIMARY KEY NOT NULL,
|
||||
"subscriptionId" varchar(255) NOT NULL,
|
||||
"notificationType" varchar(50) NOT NULL,
|
||||
@@ -52,10 +52,6 @@ export default async function migration() {
|
||||
);
|
||||
`);
|
||||
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "trialNotifications" ADD CONSTRAINT "trialNotifications_subscriptionId_subscriptions_subscriptionId_fk" FOREIGN KEY ("subscriptionId") REFERENCES "public"."subscriptions"("subscriptionId") ON DELETE cascade ON UPDATE no action;
|
||||
`);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log("Migrated database");
|
||||
} catch (e) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user