diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 66f968b02..989e111a7 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -1,4 +1,12 @@ -import { db, loginPage, LoginPage, loginPageOrg, Org, orgs, roles } from "@server/db"; +import { + db, + loginPage, + LoginPage, + loginPageOrg, + Org, + orgs, + roles +} from "@server/db"; import { Resource, ResourcePassword, @@ -12,14 +20,12 @@ import { resources, roleResources, sessions, - userOrgRoles, - userOrgs, userResources, users, ResourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; export type ResourceWithAuth = { resource: Resource | null; @@ -121,7 +127,7 @@ export async function getRoleName(roleId: number): Promise { */ export async function getRoleResourceAccess( resourceId: number, - roleId: number + roleIds: number[] ) { const roleResourceAccess = await db .select() @@ -129,12 +135,11 @@ export async function getRoleResourceAccess( .where( and( eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) + inArray(roleResources.roleId, roleIds) ) - ) - .limit(1); + ); - return roleResourceAccess.length > 0 ? roleResourceAccess[0] : null; + return roleResourceAccess.length > 0 ? roleResourceAccess : null; } /** diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index f6c8592e9..c3e796fc1 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -79,6 +79,7 @@ export const configSchema = z .default(3001) .transform(stoi) .pipe(portSchema), + badger_override: z.string().optional(), next_port: portSchema .optional() .default(3002) diff --git a/server/lib/userOrgRoles.ts b/server/lib/userOrgRoles.ts index 5a4d75659..c3db64af3 100644 --- a/server/lib/userOrgRoles.ts +++ b/server/lib/userOrgRoles.ts @@ -1,4 +1,4 @@ -import { db, userOrgRoles } from "@server/db"; +import { db, roles, userOrgRoles } from "@server/db"; import { and, eq } from "drizzle-orm"; /** @@ -20,3 +20,17 @@ export async function getUserOrgRoleIds( ); return rows.map((r) => r.roleId); } + +export async function getUserOrgRoles( + userId: string, + orgId: string +): Promise<{ roleId: number; roleName: string }[]> { + const rows = await db + .select({ roleId: userOrgRoles.roleId, roleName: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) + ); + return rows; +} diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 5ca720594..13a6f70e0 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -124,6 +124,23 @@ const getRoleResourceAccessParamsSchema = z.strictObject({ .pipe(z.int().positive("Resource ID must be a positive integer")) }); +const getResourceAccessParamsSchema = z.strictObject({ + resourceId: z + .string() + .transform(Number) + .pipe(z.int().positive("Resource ID must be a positive integer")) +}); + +const getResourceAccessQuerySchema = z.strictObject({ + roleIds: z + .union([z.array(z.string()), z.string()]) + .transform((val) => + (Array.isArray(val) ? val : [val]) + .map(Number) + .filter((n) => !isNaN(n)) + ) +}); + const getUserResourceAccessParamsSchema = z.strictObject({ userId: z.string().min(1, "User ID is required"), resourceId: z @@ -769,7 +786,7 @@ hybridRouter.get( // Get user organization role hybridRouter.get( - "/user/:userId/org/:orgId/role", + "/user/:userId/org/:orgId/roles", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getUserOrgRoleParamsSchema.safeParse( @@ -805,6 +822,82 @@ hybridRouter.get( ); } + const userOrgRoleRows = await db + .select({ roleId: userOrgRoles.roleId, roleName: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(roles.roleId, userOrgRoles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows); + + return response<{ roleId: number, roleName: string }[]>(res, { + data: userOrgRoleRows, + success: true, + error: false, + message: + userOrgRoleRows.length > 0 + ? "User org roles retrieved successfully" + : "User has no roles in this organization", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get user org role" + ) + ); + } + } +); + +// DEPRICATED Get user organization role +// used for backward compatibility with old remote nodes +hybridRouter.get( + "/user/:userId/org/:orgId/role", // <- note the missing s + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getUserOrgRoleParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, orgId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "User is not authorized to access this organization" + ) + ); + } + + // get the roles on the user + const userOrgRoleRows = await db .select({ roleId: userOrgRoles.roleId }) .from(userOrgRoles) @@ -817,8 +910,35 @@ hybridRouter.get( const roleIds = userOrgRoleRows.map((r) => r.roleId); - return response(res, { - data: roleIds, + let roleId: number | null = null; + + if (userOrgRoleRows.length === 0) { + // User has no roles in this organization + roleId = null; + } else if (userOrgRoleRows.length === 1) { + // User has exactly one role, return it + roleId = userOrgRoleRows[0].roleId; + } else { + // User has multiple roles + // Check if any of these roles are also assigned to a resource + // If we find a match, prefer that role; otherwise return the first role + // Get all resources that have any of these roles assigned + const roleResourceMatches = await db + .select({ roleId: roleResources.roleId }) + .from(roleResources) + .where(inArray(roleResources.roleId, roleIds)) + .limit(1); + if (roleResourceMatches.length > 0) { + // Return the first role that's also on a resource + roleId = roleResourceMatches[0].roleId; + } else { + // No resource match found, return the first role + roleId = userOrgRoleRows[0].roleId; + } + } + + return response<{ roleId: number | null }>(res, { + data: { roleId }, success: true, error: false, message: @@ -939,7 +1059,9 @@ hybridRouter.get( data: role?.name ?? null, success: true, error: false, - message: role ? "Role name retrieved successfully" : "Role not found", + message: role + ? "Role name retrieved successfully" + : "Role not found", status: HttpCode.OK }); } catch (error) { @@ -1039,6 +1161,101 @@ hybridRouter.get( } ); +// Check if role has access to resource +hybridRouter.get( + "/resource/:resourceId/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getResourceAccessParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const parsedQuery = getResourceAccessQuerySchema.safeParse( + req.query + ); + const roleIds = parsedQuery.success ? parsedQuery.data.roleIds : []; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + resource.orgId + ) + ) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const roleResourceAccess = await db + .select({ + resourceId: roleResources.resourceId, + roleId: roleResources.roleId + }) + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ); + + const result = + roleResourceAccess.length > 0 ? roleResourceAccess : null; + + return response<{ resourceId: number; roleId: number }[] | null>( + res, + { + data: result, + success: true, + error: false, + message: result + ? "Role resource access retrieved successfully" + : "Role resource access not found", + status: HttpCode.OK + } + ); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get role resource access" + ) + ); + } + } +); + // Check if user has direct access to resource hybridRouter.get( "/user/:userId/resource/:resourceId/access", @@ -1937,7 +2154,8 @@ hybridRouter.post( // userAgent: data.userAgent, // TODO: add this // headers: data.body.headers, // query: data.body.query, - originalRequestURL: sanitizeString(logEntry.originalRequestURL) ?? "", + originalRequestURL: + sanitizeString(logEntry.originalRequestURL) ?? "", scheme: sanitizeString(logEntry.scheme) ?? "", host: sanitizeString(logEntry.host) ?? "", path: sanitizeString(logEntry.path) ?? "", diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 182b35dcb..0b1cc1183 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -3,13 +3,12 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke import { getResourceByDomain, getResourceRules, - getRoleName, getRoleResourceAccess, getUserResourceAccess, getOrgLoginPage, getUserSessionWithUser } from "@server/db/queries/verifySessionQueries"; -import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import { LoginPage, Org, @@ -31,7 +30,6 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; import { getAsnForIp } from "@server/lib/asn"; -import { getOrgTierData } from "#dynamic/lib/billing"; import { verifyPassword } from "@server/auth/password"; import { checkOrgAccessPolicy, @@ -798,7 +796,8 @@ async function notAllowed( ) { let loginPage: LoginPage | null = null; if (orgId) { - const subscribed = await isSubscribed( // this is fine because the org login page is only a saas feature + const subscribed = await isSubscribed( + // this is fine because the org login page is only a saas feature orgId, tierMatrix.loginPageDomain ); @@ -855,7 +854,10 @@ async function headerAuthChallenged( ) { let loginPage: LoginPage | null = null; if (orgId) { - const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain); // this is fine because the org login page is only a saas feature + const subscribed = await isSubscribed( + orgId, + tierMatrix.loginPageDomain + ); // this is fine because the org login page is only a saas feature if (subscribed) { loginPage = await getOrgLoginPage(orgId); } @@ -917,9 +919,9 @@ async function isUserAllowedToAccessResource( return null; } - const userOrgRoleIds = await getUserOrgRoleIds(user.userId, resource.orgId); + const userOrgRoles = await getUserOrgRoles(user.userId, resource.orgId); - if (!userOrgRoleIds.length) { + if (!userOrgRoles.length) { return null; } @@ -935,23 +937,16 @@ async function isUserAllowedToAccessResource( return null; } - const roleNames: string[] = []; - for (const roleId of userOrgRoleIds) { - const roleResourceAccess = await getRoleResourceAccess( - resource.resourceId, - roleId - ); - if (roleResourceAccess) { - const roleName = await getRoleName(roleId); - if (roleName) roleNames.push(roleName); - } - } - if (roleNames.length > 0) { + const roleResourceAccess = await getRoleResourceAccess( + resource.resourceId, + userOrgRoles.map((r) => r.roleId) + ); + if (roleResourceAccess && roleResourceAccess.length > 0) { return { username: user.username, email: user.email, name: user.name, - role: roleNames.join(", ") + role: userOrgRoles.map((r) => r.roleName).join(", ") }; } @@ -961,15 +956,11 @@ async function isUserAllowedToAccessResource( ); if (userResourceAccess) { - const names = await Promise.all( - userOrgRoleIds.map((id) => getRoleName(id)) - ); - const role = names.filter(Boolean).join(", ") || ""; return { username: user.username, email: user.email, name: user.name, - role + role: userOrgRoles.map((r) => r.roleName).join(", ") }; } diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index fa76190ff..02f890604 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -30,12 +30,15 @@ export async function traefikConfigProvider( traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { [badgerMiddlewareName]: { - apiBaseUrl: new URL( - "/api/v1", - `http://${ - config.getRawConfig().server.internal_hostname - }:${config.getRawConfig().server.internal_port}` - ).href, + apiBaseUrl: + config.getRawConfig().server.badger_override || + new URL( + "/api/v1", + `http://${ + config.getRawConfig().server + .internal_hostname + }:${config.getRawConfig().server.internal_port}` + ).href, userSessionCookieName: config.getRawConfig().server.session_cookie_name, @@ -61,7 +64,7 @@ export async function traefikConfigProvider( return res.status(HttpCode.OK).json(traefikConfig); } catch (e) { - logger.error(`Failed to build Traefik config: ${e}`); + logger.error(e); return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ error: "Failed to build Traefik config" }); diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 1ace73474..9ba0b9767 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -21,6 +21,7 @@ import m12 from "./scriptsPg/1.15.0"; import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; +import m16 from "./scriptsPg/1.17.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -41,7 +42,8 @@ const migrations = [ { version: "1.15.0", run: m12 }, { version: "1.15.3", run: m13 }, { version: "1.15.4", run: m14 }, - { version: "1.16.0", run: m15 } + { version: "1.16.0", run: m15 }, + { version: "1.17.0", run: m16 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index da7e6b6d1..45a29ec29 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -39,6 +39,7 @@ import m33 from "./scriptsSqlite/1.15.0"; import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; +import m37 from "./scriptsSqlite/1.17.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -75,7 +76,8 @@ const migrations = [ { version: "1.15.0", run: m33 }, { version: "1.15.3", run: m34 }, { version: "1.15.4", run: m35 }, - { version: "1.16.0", run: m36 } + { version: "1.16.0", run: m36 }, + { version: "1.17.0", run: m37 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.17.0.ts b/server/setup/scriptsPg/1.17.0.ts new file mode 100644 index 000000000..81c42e1a9 --- /dev/null +++ b/server/setup/scriptsPg/1.17.0.ts @@ -0,0 +1,73 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.17.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Query existing roleId data from userOrgs before the transaction destroys it + const existingRolesQuery = await db.execute( + sql`SELECT "userId", "orgId", "roleId" FROM "userOrgs" WHERE "roleId" IS NOT NULL` + ); + const existingUserOrgRoles = existingRolesQuery.rows as { + userId: string; + orgId: string; + roleId: number; + }[]; + + console.log( + `Found ${existingUserOrgRoles.length} existing userOrgs role assignment(s) to migrate` + ); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "userOrgRoles" ( + "userId" varchar NOT NULL, + "orgId" varchar NOT NULL, + "roleId" integer NOT NULL, + CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId") + ); + `); + await db.execute(sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgs" DROP COLUMN "roleId";`); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + // Re-insert the preserved role assignments into the new userOrgRoles table + if (existingUserOrgRoles.length > 0) { + try { + for (const row of existingUserOrgRoles) { + await db.execute(sql` + INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId") + VALUES (${row.userId}, ${row.orgId}, ${row.roleId}) + ON CONFLICT DO NOTHING + `); + } + + console.log( + `Migrated ${existingUserOrgRoles.length} role assignment(s) into userOrgRoles` + ); + } catch (e) { + console.error( + "Error while migrating role assignments into userOrgRoles:", + e + ); + throw e; + } + } + + console.log(`${version} migration complete`); +} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.17.0.ts b/server/setup/scriptsSqlite/1.17.0.ts new file mode 100644 index 000000000..fe7d82de0 --- /dev/null +++ b/server/setup/scriptsSqlite/1.17.0.ts @@ -0,0 +1,96 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.17.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + // Query existing roleId data from userOrgs before the transaction destroys it + const existingUserOrgRoles = db + .prepare( + `SELECT "userId", "orgId", "roleId" FROM 'userOrgs' WHERE "roleId" IS NOT NULL` + ) + .all() as { userId: string; orgId: string; roleId: number }[]; + + console.log( + `Found ${existingUserOrgRoles.length} existing userOrgs role assignment(s) to migrate` + ); + + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'userOrgRoles' ( + 'userId' text NOT NULL, + 'orgId' text NOT NULL, + 'roleId' integer NOT NULL, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `CREATE UNIQUE INDEX 'userOrgRoles_userId_orgId_roleId_unique' ON 'userOrgRoles' ('userId','orgId','roleId');` + ).run(); + + db.prepare( + ` + CREATE TABLE '__new_userOrgs' ( + 'userId' text NOT NULL, + 'orgId' text NOT NULL, + 'isOwner' integer DEFAULT false NOT NULL, + 'autoProvisioned' integer DEFAULT false, + 'pamUsername' text, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';` + ).run(); + db.prepare(`DROP TABLE 'userOrgs';`).run(); + db.prepare( + `ALTER TABLE '__new_userOrgs' RENAME TO 'userOrgs';` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + // Re-insert the preserved role assignments into the new userOrgRoles table + if (existingUserOrgRoles.length > 0) { + const insertUserOrgRole = db.prepare( + `INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)` + ); + + const insertAll = db.transaction(() => { + for (const row of existingUserOrgRoles) { + insertUserOrgRole.run(row.userId, row.orgId, row.roleId); + } + }); + + insertAll(); + + console.log( + `Migrated ${existingUserOrgRoles.length} role assignment(s) into userOrgRoles` + ); + } + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} \ No newline at end of file