Compare commits

..

15 Commits

Author SHA1 Message Date
Owen Schwartz
400c5158f4 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-06-25 12:48:42 -07:00
Owen Schwartz
b639eac842 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-06-25 12:48:40 -07:00
Owen Schwartz
ae4dba03fc New translations en-us.json (Turkish)
[ci skip]
2026-06-25 12:48:38 -07:00
Owen Schwartz
4c0b6f4a5c New translations en-us.json (Russian)
[ci skip]
2026-06-25 12:48:35 -07:00
Owen Schwartz
feac3bd872 New translations en-us.json (Portuguese)
[ci skip]
2026-06-25 12:48:33 -07:00
Owen Schwartz
68e775587a New translations en-us.json (Polish)
[ci skip]
2026-06-25 12:48:31 -07:00
Owen Schwartz
5fb637faf1 New translations en-us.json (Dutch)
[ci skip]
2026-06-25 12:48:29 -07:00
Owen Schwartz
0c91240fa2 New translations en-us.json (Korean)
[ci skip]
2026-06-25 12:48:27 -07:00
Owen Schwartz
68ff911032 New translations en-us.json (Italian)
[ci skip]
2026-06-25 12:48:25 -07:00
Owen Schwartz
82355b9855 New translations en-us.json (German)
[ci skip]
2026-06-25 12:48:23 -07:00
Owen Schwartz
c801d81f7d New translations en-us.json (Danish)
[ci skip]
2026-06-25 12:48:21 -07:00
Owen Schwartz
ba4529c909 New translations en-us.json (Czech)
[ci skip]
2026-06-25 12:48:19 -07:00
Owen Schwartz
31afd41d1e New translations en-us.json (Bulgarian)
[ci skip]
2026-06-25 12:48:17 -07:00
Owen Schwartz
6a2fed1200 New translations en-us.json (Spanish)
[ci skip]
2026-06-25 12:48:15 -07:00
Owen Schwartz
0c76154abe New translations en-us.json (French)
[ci skip]
2026-06-25 12:48:13 -07:00
17 changed files with 401 additions and 364 deletions

View File

@@ -264,7 +264,7 @@ jobs:
shell: bash
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: 1.25

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC доставчик",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC доставчик",
"subnet": "Подмрежа",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "Подмрежата за конфигурацията на мрежата на тази организация.",
"customDomain": "Персонализиран домейн.",
"authPage": "Страници за автентификация.",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Poskytovatel Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Podsíť",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "Podsíť pro konfiguraci sítě této organizace.",
"customDomain": "Vlastní doména",
"authPage": "Autentizační stránky",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC udbyder",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC leverandør",
"subnet": "Subnet",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "Subnettet for denne organisations netværkskonfiguration.",
"customDomain": "Brugerdefineret domæne",
"authPage": "Autentiseringssider",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC Provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnetz",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.",
"customDomain": "Eigene Domain",
"authPage": "Authentifizierungs-Seiten",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Proveedor OAuth2/OIDC de Google",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subred",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "La subred para la configuración de red de esta organización.",
"customDomain": "Dominio personalizado",
"authPage": "Páginas de autenticación",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Fournisseur Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Sous-réseau",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "Le sous-réseau de la configuration réseau de cette organisation.",
"customDomain": "Domaine personnalisé",
"authPage": "Pages d'authentification",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Sottorete",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "La sottorete per la configurazione di rete di questa organizzazione.",
"customDomain": "Dominio Personalizzato",
"authPage": "Pagine di Autenticazione",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC 공급자",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자",
"subnet": "서브넷",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.",
"customDomain": "사용자 정의 도메인",
"authPage": "인증 페이지",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC leverandør",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnett",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.",
"customDomain": "Egendefinert domene",
"authPage": "Autentiseringssider",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnet",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.",
"customDomain": "Aangepast domein",
"authPage": "Authenticatiepagina's",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Dostawca Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Podsieć",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "Podsieć dla konfiguracji sieci tej organizacji.",
"customDomain": "Niestandardowa domena",
"authPage": "Strony uwierzytelniania",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Provedor Google OAuth2/OIDC",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Sub-rede",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "A sub-rede para a configuração de rede dessa organização.",
"customDomain": "Domínio Personalizado",
"authPage": "Páginas de Autenticação",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC провайдер",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Подсеть",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "Подсеть для конфигурации сети этой организации.",
"customDomain": "Пользовательский домен",
"authPage": "Страницы аутентификации",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı",
"subnet": "Alt ağ",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "Bu organizasyonun ağ yapılandırması için alt ağ.",
"customDomain": "Özel Alan",
"authPage": "Kimlik Sayfaları",

View File

@@ -2556,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC 提供商",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "子网",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "此组织网络配置的子网。",
"customDomain": "自定义域",
"authPage": "身份验证页面",

View File

@@ -29,7 +29,7 @@ type ClientRow = typeof clients.$inferSelect;
function runQueuedClientAssociationRebuilds(
userId: string,
queuedClients: ClientRow[]
) {
): void {
if (queuedClients.length === 0) {
return;
}
@@ -39,403 +39,425 @@ function runQueuedClientAssociationRebuilds(
uniqueClientsById.set(client.clientId, client);
}
for (const client of uniqueClientsById.values()) {
rebuildClientAssociationsFromClient(client).catch((error) => {
logger.error(
`Error rebuilding client associations for client ${client.clientId} (user ${userId}): ${String(
error
)}`
);
});
}
void (async () => {
for (const client of uniqueClientsById.values()) {
try {
await rebuildClientAssociationsFromClient(client);
} catch (error) {
logger.error(
`Failed rebuilding associations for client ${client.clientId} (user ${userId}): ${String(error)}`
);
}
}
logger.debug(
`Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})`
);
logger.debug(
`Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})`
);
})();
}
export async function calculateUserClientsForOrgs(
userId: string
): Promise<void> {
const trx = primaryDb;
const queuedAssociationRebuilds: ClientRow[] = [];
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<string, typeof roles.$inferSelect | null>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
const getOrgOlmKey = (orgId: string, olmId: string) => `${orgId}:${olmId}`;
const getRoleClientKey = (roleId: number, clientId: number) =>
`${roleId}:${clientId}`;
const getUserClientKey = (cachedUserId: string, clientId: number) =>
`${cachedUserId}:${clientId}`;
const execute = async (transaction: Transaction | typeof db) => {
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<
string,
typeof roles.$inferSelect | null
>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
const getOrg = async (orgId: string) => {
if (orgCache.has(orgId)) {
return orgCache.get(orgId) ?? null;
}
const getOrgOlmKey = (orgId: string, olmId: string) =>
`${orgId}:${olmId}`;
const getRoleClientKey = (roleId: number, clientId: number) =>
`${roleId}:${clientId}`;
const getUserClientKey = (cachedUserId: string, clientId: number) =>
`${cachedUserId}:${clientId}`;
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
return exitNodes;
};
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await trx
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
)
)
.limit(1);
existingClientCache.set(key, existingClient ?? null);
return existingClient ?? null;
};
const hasRoleClientAccess = async (roleId: number, clientId: number) => {
const key = getRoleClientKey(roleId, clientId);
if (roleClientAccessCache.has(key)) {
return roleClientAccessCache.get(key)!;
}
const [existingRoleClient] = await trx
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
return hasAccess;
};
const hasUserClientAccess = async (
cachedUserId: string,
clientId: number
) => {
const key = getUserClientKey(cachedUserId, clientId);
if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
}
const [existingUserClient] = await trx
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user
const userOlms = await trx
.select()
.from(olms)
.where(eq(olms.userId, userId));
if (userOlms.length === 0) {
// No OLMs for this user, but we should still clean up any orphaned clients
await cleanupOrphanedClients(
userId,
trx,
[],
queuedAssociationRebuilds
);
return;
}
// Get all user orgs with all roles (for org list and role-based logic)
const userOrgRoleRows = await trx
.select()
.from(userOrgs)
.innerJoin(
userOrgRoles,
and(
eq(userOrgs.userId, userOrgRoles.userId),
eq(userOrgs.orgId, userOrgRoles.orgId)
)
)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId));
const userOrgIds = [
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const orgIdToRoleRows = new Map<string, (typeof userOrgRoleRows)[0][]>();
for (const r of userOrgRoleRows) {
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
}
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
for (const orgId of orgIdToRoleRows.keys()) {
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs;
const org = await getOrg(orgId);
if (!org) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
);
continue;
const getOrg = async (orgId: string) => {
if (orgCache.has(orgId)) {
return orgCache.get(orgId) ?? null;
}
if (!org.subnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
);
continue;
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
// Get admin role for this org (needed for access grants)
const adminRole = await getAdminRole(orgId);
const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
if (!adminRole) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
);
continue;
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
// Check if a client already exists for this OLM+user+org combination
const existingClient = await getExistingClient(orgId, olm.olmId);
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
if (existingClient) {
// Ensure admin role has access to the client
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
return exitNodes;
};
if (!hasRoleAccess) {
await trx.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await transaction
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
)
)
.limit(1);
existingClientCache.set(key, existingClient ?? null);
return existingClient ?? null;
};
const hasRoleClientAccess = async (
roleId: number,
clientId: number
) => {
const key = getRoleClientKey(roleId, clientId);
if (roleClientAccessCache.has(key)) {
return roleClientAccessCache.get(key)!;
}
const [existingRoleClient] = await transaction
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
return hasAccess;
};
const hasUserClientAccess = async (
cachedUserId: string,
clientId: number
) => {
const key = getUserClientKey(cachedUserId, clientId);
if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
}
const [existingUserClient] = await transaction
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user
const userOlms = await transaction
.select()
.from(olms)
.where(eq(olms.userId, userId));
if (userOlms.length === 0) {
// No OLMs for this user, but we should still clean up any orphaned clients
await cleanupOrphanedClients(
userId,
transaction,
[],
queuedAssociationRebuilds
);
return;
}
// Get all user orgs with all roles (for org list and role-based logic)
const userOrgRoleRows = await transaction
.select()
.from(userOrgs)
.innerJoin(
userOrgRoles,
and(
eq(userOrgs.userId, userOrgRoles.userId),
eq(userOrgs.orgId, userOrgRoles.orgId)
)
)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId));
const userOrgIds = [
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const orgIdToRoleRows = new Map<
string,
(typeof userOrgRoleRows)[0][]
>();
for (const r of userOrgRoleRows) {
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
}
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
for (const orgId of orgIdToRoleRows.keys()) {
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs;
const org = await getOrg(orgId);
if (!org) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
);
continue;
}
// Ensure user has access to the client
const hasUserAccess = await hasUserClientAccess(
userId,
existingClient.clientId
if (!org.subnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
);
continue;
}
// Get admin role for this org (needed for access grants)
const adminRole = await getAdminRole(orgId);
if (!adminRole) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
);
continue;
}
// Check if a client already exists for this OLM+user+org combination
const existingClient = await getExistingClient(
orgId,
olm.olmId
);
if (!hasUserAccess) {
await trx.insert(userClients).values({
if (existingClient) {
// Ensure admin role has access to the client
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
if (!hasRoleAccess) {
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
// Ensure user has access to the client
const hasUserAccess = await hasUserClientAccess(
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
existingClient.clientId
);
if (!hasUserAccess) {
await transaction.insert(userClients).values({
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
);
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[
Math.floor(Math.random() * exitNodesList.length)
];
// Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, transaction);
const subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
type: "olm",
niceId,
approvalState: requireApproval ? "pending" : null
};
// Create the client
const [newClient] = await transaction
.insert(clients)
.values(newClientData)
.returning();
await releaseSubnetLock();
existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId),
newClient
);
// create approval request
if (requireApproval) {
await transaction
.insert(approvals)
.values({
timestamp: Math.floor(new Date().getTime() / 1000),
orgId: userOrg.orgId,
clientId: newClient.clientId,
userId,
type: "user_device"
})
.returning();
}
queuedAssociationRebuilds.push(newClient);
// Grant admin role access to the client
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await transaction.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
);
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
// Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, trx);
const subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
type: "olm",
niceId,
approvalState: requireApproval ? "pending" : null
};
// Create the client
const [newClient] = await trx
.insert(clients)
.values(newClientData)
.returning();
await releaseSubnetLock();
existingClientCache.set(getOrgOlmKey(orgId, olm.olmId), newClient);
// create approval request
if (requireApproval) {
await trx
.insert(approvals)
.values({
timestamp: Math.floor(new Date().getTime() / 1000),
orgId: userOrg.orgId,
clientId: newClient.clientId,
userId,
type: "user_device"
})
.returning();
}
queuedAssociationRebuilds.push(newClient);
// Grant admin role access to the client
await trx.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await trx.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
);
}
}
// Clean up clients in orgs the user is no longer in
await cleanupOrphanedClients(
userId,
trx,
userOrgIds,
queuedAssociationRebuilds
);
// Clean up clients in orgs the user is no longer in
await cleanupOrphanedClients(
userId,
transaction,
userOrgIds,
queuedAssociationRebuilds
);
};
runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds);
}
@@ -474,7 +496,7 @@ async function cleanupOrphanedClients(
)
.returning();
// Queue deleted clients for post-trx association cleanup.
// Queue deleted clients for post-transaction association cleanup.
for (const deletedClient of deletedClients) {
queuedAssociationRebuilds.push(deletedClient);