Merge branch 'multi-role' of https://github.com/fosrl/pangolin into multi-role

This commit is contained in:
miloschwartz
2026-03-29 12:01:55 -07:00
10 changed files with 454 additions and 49 deletions

View File

@@ -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<string | null> {
*/
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;
}
/**

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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<number[]>(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) ?? "",

View File

@@ -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(", ")
};
}

View File

@@ -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"
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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`);
}

View File

@@ -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`);
}