mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-26 17:19:09 +00:00
Compare commits
1 Commits
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba4d553aac |
3719
package-lock.json
generated
3719
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
104
package.json
104
package.json
@@ -35,7 +35,7 @@
|
||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||
"@aws-sdk/client-s3": "3.1056.0",
|
||||
"@aws-sdk/client-s3": "3.1075.0",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.4.0",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
@@ -43,38 +43,38 @@
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@oslojs/crypto": "1.0.1",
|
||||
"@oslojs/encoding": "1.1.0",
|
||||
"@radix-ui/react-avatar": "1.1.11",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-avatar": "1.2.0",
|
||||
"@radix-ui/react-checkbox": "1.3.5",
|
||||
"@radix-ui/react-collapsible": "1.1.14",
|
||||
"@radix-ui/react-dialog": "1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.18",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@radix-ui/react-label": "2.1.10",
|
||||
"@radix-ui/react-popover": "1.1.17",
|
||||
"@radix-ui/react-progress": "1.1.10",
|
||||
"@radix-ui/react-radio-group": "1.4.1",
|
||||
"@radix-ui/react-scroll-area": "1.2.12",
|
||||
"@radix-ui/react-select": "2.3.1",
|
||||
"@radix-ui/react-separator": "1.1.10",
|
||||
"@radix-ui/react-slot": "1.3.0",
|
||||
"@radix-ui/react-switch": "1.3.1",
|
||||
"@radix-ui/react-tabs": "1.1.15",
|
||||
"@radix-ui/react-toast": "1.2.17",
|
||||
"@radix-ui/react-tooltip": "1.2.10",
|
||||
"@react-email/body": "0.3.0",
|
||||
"@react-email/components": "1.0.12",
|
||||
"@react-email/render": "2.0.8",
|
||||
"@react-email/render": "2.0.9",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
"@simplewebauthn/server": "13.3.1",
|
||||
"@simplewebauthn/server": "13.3.2",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"arctic": "3.7.0",
|
||||
"axios": "1.16.1",
|
||||
"axios": "1.18.1",
|
||||
"better-sqlite3": "11.9.1",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -91,40 +91,40 @@
|
||||
"helmet": "8.2.0",
|
||||
"http-errors": "2.0.1",
|
||||
"input-otp": "1.4.2",
|
||||
"ioredis": "5.11.0",
|
||||
"ioredis": "5.11.1",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "4.2.0",
|
||||
"js-yaml": "5.1.0",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lucide-react": "1.17.0",
|
||||
"lucide-react": "1.21.0",
|
||||
"maxmind": "5.0.6",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.6",
|
||||
"next": "16.2.9",
|
||||
"next-intl": "4.13.0",
|
||||
"next-themes": "0.4.6",
|
||||
"nextjs-toploader": "3.9.17",
|
||||
"node-cache": "5.1.2",
|
||||
"nodemailer": "9.0.1",
|
||||
"oslo": "1.2.1",
|
||||
"pg": "8.21.0",
|
||||
"posthog-node": "5.35.6",
|
||||
"pg": "8.22.0",
|
||||
"posthog-node": "5.38.5",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.2.6",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "19.2.6",
|
||||
"react": "19.2.7",
|
||||
"react-day-picker": "10.0.1",
|
||||
"react-dom": "19.2.7",
|
||||
"react-easy-sort": "1.8.0",
|
||||
"react-hook-form": "7.76.1",
|
||||
"react-hook-form": "7.80.0",
|
||||
"react-icons": "5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"recharts": "3.9.0",
|
||||
"reodotdev": "1.1.0",
|
||||
"semver": "7.8.1",
|
||||
"semver": "7.8.5",
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.2.0",
|
||||
"stripe": "22.3.0",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
"uuid": "14.0.0",
|
||||
"uuid": "14.0.1",
|
||||
"vaul": "1.1.2",
|
||||
"visionscarto-world-atlas": "1.0.0",
|
||||
"winston": "3.19.0",
|
||||
@@ -136,11 +136,11 @@
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dotenvx/dotenvx": "1.69.1",
|
||||
"@dotenvx/dotenvx": "1.75.1",
|
||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@react-email/ui": "^6.6.5",
|
||||
"@tailwindcss/postcss": "4.3.1",
|
||||
"@tanstack/react-query-devtools": "5.101.1",
|
||||
"@types/better-sqlite3": "7.6.13",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
@@ -151,14 +151,14 @@
|
||||
"@types/jmespath": "0.15.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonwebtoken": "9.0.10",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/nodemailer": "8.0.0",
|
||||
"@types/node": "26.0.1",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/nprogress": "0.2.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sshpk": "1.17.4",
|
||||
"@types/sshpk": "1.17.5",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/topojson-client": "3.1.5",
|
||||
"@types/ws": "8.18.1",
|
||||
@@ -166,17 +166,17 @@
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"drizzle-kit": "0.31.10",
|
||||
"esbuild": "0.28.1",
|
||||
"esbuild-node-externals": "1.22.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"esbuild-node-externals": "1.23.1",
|
||||
"eslint": "10.5.0",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"react-email": "6.5.0",
|
||||
"tailwindcss": "4.3.0",
|
||||
"prettier": "3.8.4",
|
||||
"react-email": "6.6.5",
|
||||
"tailwindcss": "4.3.1",
|
||||
"tsc-alias": "1.8.17",
|
||||
"tsx": "4.22.3",
|
||||
"tsx": "4.22.4",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.0"
|
||||
"typescript-eslint": "8.62.0"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "0.28.1",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user