Merge branch 'multi-role' into dev

This commit is contained in:
Owen
2026-03-29 13:55:23 -07:00
126 changed files with 5337 additions and 2014 deletions

View File

@@ -208,7 +208,7 @@ export async function listAccessTokens(
.where(
or(
eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!)
inArray(roleResources.roleId, req.userOrgRoleIds!)
)
);
} else {

View File

@@ -4,11 +4,11 @@ import {
getResourceByDomain,
getResourceRules,
getRoleResourceAccess,
getUserOrgRole,
getUserResourceAccess,
getOrgLoginPage,
getUserSessionWithUser
} from "@server/db/queries/verifySessionQueries";
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
import {
LoginPage,
Org,
@@ -30,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,
@@ -797,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
);
@@ -854,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);
}
@@ -916,9 +919,9 @@ async function isUserAllowedToAccessResource(
return null;
}
const userOrgRole = await getUserOrgRole(user.userId, resource.orgId);
const userOrgRoles = await getUserOrgRoles(user.userId, resource.orgId);
if (!userOrgRole) {
if (!userOrgRoles.length) {
return null;
}
@@ -936,15 +939,14 @@ async function isUserAllowedToAccessResource(
const roleResourceAccess = await getRoleResourceAccess(
resource.resourceId,
userOrgRole.roleId
userOrgRoles.map((r) => r.roleId)
);
if (roleResourceAccess) {
if (roleResourceAccess && roleResourceAccess.length > 0) {
return {
username: user.username,
email: user.email,
name: user.name,
role: userOrgRole.roleName
role: userOrgRoles.map((r) => r.roleName).join(", ")
};
}
@@ -958,7 +960,7 @@ async function isUserAllowedToAccessResource(
username: user.username,
email: user.email,
name: user.name,
role: userOrgRole.roleName
role: userOrgRoles.map((r) => r.roleName).join(", ")
};
}

View File

@@ -92,7 +92,7 @@ export async function createClient(
const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) {
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -234,7 +234,7 @@ export async function createClient(
clientId: newClient.clientId
});
if (req.user && req.userOrgRoleId != adminRole.roleId) {
if (req.user && !req.userOrgRoleIds?.includes(adminRole.roleId)) {
// make sure the user can access the client
trx.insert(userClients).values({
userId: req.user.userId,

View File

@@ -297,7 +297,7 @@ export async function listClients(
.where(
or(
eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!)
inArray(roleClients.roleId, req.userOrgRoleIds!)
)
);
} else {

View File

@@ -316,7 +316,7 @@ export async function listUserDevices(
.where(
or(
eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!)
inArray(roleClients.roleId, req.userOrgRoleIds!)
)
);
} else {

View File

@@ -644,6 +644,7 @@ authenticated.delete(
logActionAudit(ActionsEnum.deleteRole),
role.deleteRole
);
authenticated.post(
"/role/:roleId/add/:userId",
verifyRoleAccess,
@@ -651,7 +652,7 @@ authenticated.post(
verifyLimits,
verifyUserHasAction(ActionsEnum.addUserRole),
logActionAudit(ActionsEnum.addUserRole),
user.addUserRole
user.addUserRoleLegacy
);
authenticated.post(

View File

@@ -25,7 +25,8 @@ const bodySchema = z.strictObject({
namePath: z.string().optional(),
scopes: z.string().nonempty(),
autoProvision: z.boolean().optional(),
tags: z.string().optional()
tags: z.string().optional(),
variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc")
});
export type CreateIdpResponse = {
@@ -77,7 +78,8 @@ export async function createOidcIdp(
namePath,
name,
autoProvision,
tags
tags,
variant
} = parsedBody.data;
if (
@@ -121,7 +123,8 @@ export async function createOidcIdp(
scopes,
identifierPath,
emailPath,
namePath
namePath,
variant
});
});

View File

@@ -31,7 +31,8 @@ const bodySchema = z.strictObject({
autoProvision: z.boolean().optional(),
defaultRoleMapping: z.string().optional(),
defaultOrgMapping: z.string().optional(),
tags: z.string().optional()
tags: z.string().optional(),
variant: z.enum(["oidc", "google", "azure"]).optional()
});
export type UpdateIdpResponse = {
@@ -96,7 +97,8 @@ export async function updateOidcIdp(
autoProvision,
defaultRoleMapping,
defaultOrgMapping,
tags
tags,
variant
} = parsedBody.data;
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
@@ -159,7 +161,8 @@ export async function updateOidcIdp(
scopes,
identifierPath,
emailPath,
namePath
namePath,
variant
};
keysToUpdate = Object.keys(configData).filter(

View File

@@ -13,6 +13,7 @@ import {
orgs,
Role,
roles,
userOrgRoles,
userOrgs,
users
} from "@server/db";
@@ -35,11 +36,13 @@ import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import {
assignUserToOrg,
removeUserFromOrg
} from "@server/lib/userOrg";
import { unwrapRoleMapping } from "@app/lib/idpRoleMapping";
const ensureTrailingSlash = (url: string): string => {
return url;
@@ -366,7 +369,7 @@ export async function validateOidcCallback(
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
const userOrgInfo: { orgId: string; roleId: number }[] = [];
const userOrgInfo: { orgId: string; roleIds: number[] }[] = [];
for (const org of allOrgs) {
const [idpOrgRes] = await db
.select()
@@ -378,8 +381,6 @@ export async function validateOidcCallback(
)
);
let roleId: number | undefined = undefined;
const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping;
const hydratedOrgMapping = hydrateOrgMapping(
orgMapping,
@@ -404,38 +405,55 @@ export async function validateOidcCallback(
idpOrgRes?.roleMapping || defaultRoleMapping;
if (roleMapping) {
logger.debug("Role Mapping", { roleMapping });
const roleName = jmespath.search(claims, roleMapping);
const roleMappingJmes = unwrapRoleMapping(
roleMapping
).evaluationExpression;
const roleMappingResult = jmespath.search(
claims,
roleMappingJmes
);
const roleNames = normalizeRoleMappingResult(
roleMappingResult
);
if (!roleName) {
logger.error("Role name not found in the ID token", {
roleName
const supportsMultiRole = await isLicensedOrSubscribed(
org.orgId,
tierMatrix.fullRbac
);
const effectiveRoleNames = supportsMultiRole
? roleNames
: roleNames.slice(0, 1);
if (!effectiveRoleNames.length) {
logger.error("Role mapping returned no valid roles", {
roleMappingResult
});
continue;
}
const [roleRes] = await db
const roleRes = await db
.select()
.from(roles)
.where(
and(
eq(roles.orgId, org.orgId),
eq(roles.name, roleName)
inArray(roles.name, effectiveRoleNames)
)
);
if (!roleRes) {
logger.error("Role not found", {
if (!roleRes.length) {
logger.error("No mapped roles found in organization", {
orgId: org.orgId,
roleName
roleNames: effectiveRoleNames
});
continue;
}
roleId = roleRes.roleId;
const roleIds = [...new Set(roleRes.map((r) => r.roleId))];
userOrgInfo.push({
orgId: org.orgId,
roleId
roleIds
});
}
}
@@ -570,32 +588,28 @@ export async function validateOidcCallback(
}
}
// Update roles for existing auto-provisioned orgs where the role has changed
const orgsToUpdate = autoProvisionedOrgs.filter(
(currentOrg) => {
const newOrg = userOrgInfo.find(
(newOrg) => newOrg.orgId === currentOrg.orgId
);
return newOrg && newOrg.roleId !== currentOrg.roleId;
}
);
// Sync roles 1:1 with IdP policy for existing auto-provisioned orgs
for (const currentOrg of autoProvisionedOrgs) {
const newRole = userOrgInfo.find(
(newOrg) => newOrg.orgId === currentOrg.orgId
);
if (!newRole) continue;
if (orgsToUpdate.length > 0) {
for (const org of orgsToUpdate) {
const newRole = userOrgInfo.find(
(newOrg) => newOrg.orgId === org.orgId
await trx
.delete(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, userId!),
eq(userOrgRoles.orgId, currentOrg.orgId)
)
);
if (newRole) {
await trx
.update(userOrgs)
.set({ roleId: newRole.roleId })
.where(
and(
eq(userOrgs.userId, userId!),
eq(userOrgs.orgId, org.orgId)
)
);
}
for (const roleId of newRole.roleIds) {
await trx.insert(userOrgRoles).values({
userId: userId!,
orgId: currentOrg.orgId,
roleId
});
}
}
@@ -609,6 +623,10 @@ export async function validateOidcCallback(
if (orgsToAdd.length > 0) {
for (const org of orgsToAdd) {
if (org.roleIds.length === 0) {
continue;
}
const [fullOrg] = await trx
.select()
.from(orgs)
@@ -619,9 +637,9 @@ export async function validateOidcCallback(
{
orgId: org.orgId,
userId: userId!,
roleId: org.roleId,
autoProvisioned: true,
},
org.roleIds,
trx
);
}
@@ -748,3 +766,25 @@ function hydrateOrgMapping(
}
return orgMapping.split("{{orgId}}").join(orgId);
}
function normalizeRoleMappingResult(
result: unknown
): string[] {
if (typeof result === "string") {
const role = result.trim();
return role ? [role] : [];
}
if (Array.isArray(result)) {
return [
...new Set(
result
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter(Boolean)
)
];
}
return [];
}

View File

@@ -16,6 +16,7 @@ import {
verifyApiKey,
verifyApiKeyOrgAccess,
verifyApiKeyHasAction,
verifyApiKeyCanSetUserOrgRoles,
verifyApiKeySiteAccess,
verifyApiKeyResourceAccess,
verifyApiKeyTargetAccess,
@@ -595,7 +596,7 @@ authenticated.post(
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.addUserRole),
logActionAudit(ActionsEnum.addUserRole),
user.addUserRole
user.addUserRoleLegacy
);
authenticated.post(

View File

@@ -46,7 +46,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data;
if (req.user && !req.userOrgRoleId) {
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);

View File

@@ -46,7 +46,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data;
if (req.user && !req.userOrgRoleId) {
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idp, idpOidcConfig } from "@server/db";
import { roles, userOrgs, users } from "@server/db";
import { roles, userOrgRoles, userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -14,7 +14,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy";
async function queryUser(orgId: string, userId: string) {
const [user] = await db
const [userRow] = await db
.select({
orgId: userOrgs.orgId,
userId: users.userId,
@@ -22,10 +22,7 @@ async function queryUser(orgId: string, userId: string) {
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled,
autoProvisioned: userOrgs.autoProvisioned,
idpId: users.idpId,
@@ -35,13 +32,40 @@ async function queryUser(orgId: string, userId: string) {
idpAutoProvision: idp.autoProvision
})
.from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
return user;
if (!userRow) return undefined;
const roleRows = await db
.select({
roleId: userOrgRoles.roleId,
roleName: roles.name,
isAdmin: roles.isAdmin
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
);
const isAdmin = roleRows.some((r) => r.isAdmin);
return {
...userRow,
isAdmin,
roleIds: roleRows.map((r) => r.roleId),
roles: roleRows.map((r) => ({
roleId: r.roleId,
name: r.roleName ?? ""
}))
};
}
export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult;

View File

@@ -9,6 +9,7 @@ import {
orgs,
roleActions,
roles,
userOrgRoles,
userOrgs,
users,
actions
@@ -312,9 +313,13 @@ export async function createOrg(
await trx.insert(userOrgs).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true
});
await trx.insert(userOrgRoles).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,
roleId
});
ownerUserId = req.user!.userId;
} else {
// if org created by root api key, set the server admin as the owner
@@ -332,9 +337,13 @@ export async function createOrg(
await trx.insert(userOrgs).values({
userId: serverAdmin.userId,
orgId: newOrg[0].orgId,
roleId: roleId,
isOwner: true
});
await trx.insert(userOrgRoles).values({
userId: serverAdmin.userId,
orgId: newOrg[0].orgId,
roleId
});
ownerUserId = serverAdmin.userId;
}

View File

@@ -117,20 +117,26 @@ export async function getOrgOverview(
.from(userOrgs)
.where(eq(userOrgs.orgId, orgId));
const [role] = await db
.select()
.from(roles)
.where(eq(roles.roleId, req.userOrg.roleId));
const roleIds = req.userOrgRoleIds ?? [];
const roleRows =
roleIds.length > 0
? await db
.select({ name: roles.name, isAdmin: roles.isAdmin })
.from(roles)
.where(inArray(roles.roleId, roleIds))
: [];
const userRoleName = roleRows.map((r) => r.name ?? "").join(", ") ?? "";
const isAdmin = roleRows.some((r) => r.isAdmin === true);
return response<GetOrgOverviewResponse>(res, {
data: {
orgName: org[0].name,
orgId: org[0].orgId,
userRoleName: role.name,
userRoleName,
numSites,
numUsers,
numResources,
isAdmin: role.isAdmin || false,
isAdmin,
isOwner: req.userOrg?.isOwner || false
},
success: true,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, roles } from "@server/db";
import { Org, orgs, userOrgs } from "@server/db";
import { Org, orgs, userOrgRoles, userOrgs } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -82,10 +82,7 @@ export async function listUserOrgs(
const { userId } = parsedParams.data;
const userOrganizations = await db
.select({
orgId: userOrgs.orgId,
roleId: userOrgs.roleId
})
.select({ orgId: userOrgs.orgId })
.from(userOrgs)
.where(eq(userOrgs.userId, userId));
@@ -116,10 +113,27 @@ export async function listUserOrgs(
userOrgs,
and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId))
)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.limit(limit)
.offset(offset);
const roleRows = await db
.select({
orgId: userOrgRoles.orgId,
isAdmin: roles.isAdmin
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.userId, userId),
inArray(userOrgRoles.orgId, userOrgIds)
)
);
const orgHasAdmin = new Set(
roleRows.filter((r) => r.isAdmin).map((r) => r.orgId)
);
const totalCountResult = await db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(orgs)
@@ -133,8 +147,8 @@ export async function listUserOrgs(
if (val.userOrgs && val.userOrgs.isOwner) {
res.isOwner = val.userOrgs.isOwner;
}
if (val.roles && val.roles.isAdmin) {
res.isAdmin = val.roles.isAdmin;
if (val.orgs && orgHasAdmin.has(val.orgs.orgId)) {
res.isAdmin = true;
}
if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) {
res.isPrimaryOrg = val.orgs.isBillingOrg;

View File

@@ -112,7 +112,7 @@ export async function createResource(
const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) {
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -292,7 +292,7 @@ async function createHttpResource(
resourceId: newResource[0].resourceId
});
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
// make sure the user can access the resource
await trx.insert(userResources).values({
userId: req.user?.userId!,
@@ -385,7 +385,7 @@ async function createRawResource(
resourceId: newResource[0].resourceId
});
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
// make sure the user can access the resource
await trx.insert(userResources).values({
userId: req.user?.userId!,

View File

@@ -5,6 +5,7 @@ import {
resources,
userResources,
roleResources,
userOrgRoles,
userOrgs,
resourcePassword,
resourcePincode,
@@ -32,22 +33,29 @@ export async function getUserResources(
);
}
// First get the user's role in the organization
const userOrgResult = await db
.select({
roleId: userOrgs.roleId
})
// Check user is in organization and get their role IDs
const [userOrg] = await db
.select()
.from(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
if (userOrgResult.length === 0) {
if (!userOrg) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User not in organization")
);
}
const userRoleId = userOrgResult[0].roleId;
const userRoleIds = await db
.select({ roleId: userOrgRoles.roleId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
)
.then((rows) => rows.map((r) => r.roleId));
// Get resources accessible through direct assignment or role assignment
const directResourcesQuery = db
@@ -55,20 +63,28 @@ export async function getUserResources(
.from(userResources)
.where(eq(userResources.userId, userId));
const roleResourcesQuery = db
.select({ resourceId: roleResources.resourceId })
.from(roleResources)
.where(eq(roleResources.roleId, userRoleId));
const roleResourcesQuery =
userRoleIds.length > 0
? db
.select({ resourceId: roleResources.resourceId })
.from(roleResources)
.where(inArray(roleResources.roleId, userRoleIds))
: Promise.resolve([]);
const directSiteResourcesQuery = db
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
.where(eq(userSiteResources.userId, userId));
const roleSiteResourcesQuery = db
.select({ siteResourceId: roleSiteResources.siteResourceId })
.from(roleSiteResources)
.where(eq(roleSiteResources.roleId, userRoleId));
const roleSiteResourcesQuery =
userRoleIds.length > 0
? db
.select({
siteResourceId: roleSiteResources.siteResourceId
})
.from(roleSiteResources)
.where(inArray(roleSiteResources.roleId, userRoleIds))
: Promise.resolve([]);
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
directResourcesQuery,

View File

@@ -305,7 +305,7 @@ export async function listResources(
.where(
or(
eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!)
inArray(roleResources.roleId, req.userOrgRoleIds!)
)
);
} else {

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { roles, userOrgs } from "@server/db";
import { eq } from "drizzle-orm";
import { roles, userOrgRoles } from "@server/db";
import { and, eq, exists, aliasedTable } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -114,13 +114,32 @@ export async function deleteRole(
}
await db.transaction(async (trx) => {
// move all users from the userOrgs table with roleId to newRoleId
await trx
.update(userOrgs)
.set({ roleId: newRoleId })
.where(eq(userOrgs.roleId, roleId));
const uorNewRole = aliasedTable(userOrgRoles, "user_org_roles_new");
// Users who already have newRoleId: drop the old assignment only (unique on userId+orgId+roleId).
await trx.delete(userOrgRoles).where(
and(
eq(userOrgRoles.roleId, roleId),
exists(
trx
.select()
.from(uorNewRole)
.where(
and(
eq(uorNewRole.userId, userOrgRoles.userId),
eq(uorNewRole.orgId, userOrgRoles.orgId),
eq(uorNewRole.roleId, newRoleId)
)
)
)
)
);
await trx
.update(userOrgRoles)
.set({ roleId: newRoleId })
.where(eq(userOrgRoles.roleId, roleId));
// delete the old role
await trx.delete(roles).where(eq(roles.roleId, roleId));
});

View File

@@ -111,7 +111,7 @@ export async function createSite(
const { orgId } = parsedParams.data;
if (req.user && !req.userOrgRoleId) {
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@@ -399,7 +399,7 @@ export async function createSite(
siteId: newSite.siteId
});
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) {
// make sure the user can access the site
trx.insert(userSites).values({
userId: req.user?.userId!,

View File

@@ -235,7 +235,7 @@ export async function listSites(
.where(
or(
eq(userSites.userId, req.user!.userId),
eq(roleSites.roleId, req.userOrgRoleId!)
inArray(roleSites.roleId, req.userOrgRoleIds!)
)
);
} else {

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

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, orgs, UserOrg } from "@server/db";
import { roles, userInvites, userOrgs, users } from "@server/db";
import { eq, and, inArray, ne } from "drizzle-orm";
import { db, orgs } from "@server/db";
import { roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -141,17 +141,34 @@ export async function acceptInvite(
);
}
let roleId: number;
// get the role to make sure it exists
const existingRole = await db
const inviteRoleRows = await db
.select({ roleId: userInviteRoles.roleId })
.from(userInviteRoles)
.where(eq(userInviteRoles.inviteId, inviteId));
const inviteRoleIds = [
...new Set(inviteRoleRows.map((r) => r.roleId))
];
if (inviteRoleIds.length === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"This invitation has no roles. Please contact an admin."
)
);
}
const existingRoles = await db
.select()
.from(roles)
.where(eq(roles.roleId, existingInvite.roleId))
.limit(1);
if (existingRole.length) {
roleId = existingRole[0].roleId;
} else {
// TODO: use the default role on the org instead of failing
.where(
and(
eq(roles.orgId, existingInvite.orgId),
inArray(roles.roleId, inviteRoleIds)
)
);
if (existingRoles.length !== inviteRoleIds.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -165,9 +182,9 @@ export async function acceptInvite(
org,
{
userId: existingUser[0].userId,
orgId: existingInvite.orgId,
roleId: existingInvite.roleId
orgId: existingInvite.orgId
},
inviteRoleIds,
trx
);

View File

@@ -1,42 +1,44 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { clients, db, UserOrg } from "@server/db";
import { userOrgs, roles } from "@server/db";
import stoi from "@server/lib/stoi";
import { clients, db } from "@server/db";
import { userOrgRoles, userOrgs, roles } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import stoi from "@server/lib/stoi";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
const addUserRoleParamsSchema = z.strictObject({
userId: z.string(),
roleId: z.string().transform(stoi).pipe(z.number())
/** Legacy path param order: /role/:roleId/add/:userId */
const addUserRoleLegacyParamsSchema = z.strictObject({
roleId: z.string().transform(stoi).pipe(z.number()),
userId: z.string()
});
export type AddUserRoleResponse = z.infer<typeof addUserRoleParamsSchema>;
registry.registerPath({
method: "post",
path: "/role/{roleId}/add/{userId}",
description: "Add a role to a user.",
description:
"Legacy: set exactly one role for the user (replaces any other roles the user has in the org).",
tags: [OpenAPITags.Role, OpenAPITags.User],
request: {
params: addUserRoleParamsSchema
params: addUserRoleLegacyParamsSchema
},
responses: {}
});
export async function addUserRole(
export async function addUserRoleLegacy(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = addUserRoleParamsSchema.safeParse(req.params);
const parsedParams = addUserRoleLegacyParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
@@ -57,7 +59,6 @@ export async function addUserRole(
);
}
// get the role
const [role] = await db
.select()
.from(roles)
@@ -70,7 +71,7 @@ export async function addUserRole(
);
}
const existingUser = await db
const [existingUser] = await db
.select()
.from(userOrgs)
.where(
@@ -78,7 +79,7 @@ export async function addUserRole(
)
.limit(1);
if (existingUser.length === 0) {
if (!existingUser) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -87,7 +88,7 @@ export async function addUserRole(
);
}
if (existingUser[0].isOwner) {
if (existingUser.isOwner) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -96,13 +97,13 @@ export async function addUserRole(
);
}
const roleExists = await db
const [roleInOrg] = await db
.select()
.from(roles)
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId)))
.limit(1);
if (roleExists.length === 0) {
if (!roleInOrg) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -111,20 +112,22 @@ export async function addUserRole(
);
}
let newUserRole: UserOrg | null = null;
await db.transaction(async (trx) => {
[newUserRole] = await trx
.update(userOrgs)
.set({ roleId })
await trx
.delete(userOrgRoles)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, role.orgId)
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, role.orgId)
)
)
.returning();
);
await trx.insert(userOrgRoles).values({
userId,
orgId: role.orgId,
roleId
});
// get the client associated with this user in this org
const orgClients = await trx
.select()
.from(clients)
@@ -133,17 +136,15 @@ export async function addUserRole(
eq(clients.userId, userId),
eq(clients.orgId, role.orgId)
)
)
.limit(1);
);
for (const orgClient of orgClients) {
// we just changed the user's role, so we need to rebuild client associations and what they have access to
await rebuildClientAssociationsFromClient(orgClient, trx);
}
});
return response(res, {
data: newUserRole,
data: { ...existingUser, roleId },
success: true,
error: false,
message: "Role added to user successfully",

View File

@@ -6,8 +6,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { db, orgs, UserOrg } from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm";
import { db, orgs } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db";
import { generateId } from "@server/auth/sessions/app";
import { usageService } from "@server/lib/billing/usageService";
@@ -15,21 +15,43 @@ import { FeatureId } from "@server/lib/billing";
import { build } from "@server/build";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { assignUserToOrg } from "@server/lib/userOrg";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
});
const bodySchema = z.strictObject({
email: z.string().email().toLowerCase().optional(),
username: z.string().nonempty().toLowerCase(),
name: z.string().optional(),
type: z.enum(["internal", "oidc"]).optional(),
idpId: z.number().optional(),
roleId: z.number()
});
const bodySchema = z
.strictObject({
email: z.string().email().toLowerCase().optional(),
username: z.string().nonempty().toLowerCase(),
name: z.string().optional(),
type: z.enum(["internal", "oidc"]).optional(),
idpId: z.number().optional(),
roleIds: z.array(z.number().int().positive()).min(1).optional(),
roleId: z.number().int().positive().optional()
})
.refine(
(d) =>
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
{ message: "roleIds or roleId is required", path: ["roleIds"] }
)
.transform((data) => ({
email: data.email,
username: data.username,
name: data.name,
type: data.type,
idpId: data.idpId,
roleIds: [
...new Set(
data.roleIds && data.roleIds.length > 0
? data.roleIds
: [data.roleId!]
)
]
}));
export type CreateOrgUserResponse = {};
@@ -78,7 +100,8 @@ export async function createOrgUser(
}
const { orgId } = parsedParams.data;
const { username, email, name, type, idpId, roleId } = parsedBody.data;
const { username, email, name, type, idpId, roleIds: uniqueRoleIds } =
parsedBody.data;
if (build == "saas") {
const usage = await usageService.getUsage(orgId, FeatureId.USERS);
@@ -109,17 +132,6 @@ export async function createOrgUser(
}
}
const [role] = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId));
if (!role) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Role ID not found")
);
}
if (type === "internal") {
return next(
createHttpError(
@@ -152,6 +164,38 @@ export async function createOrgUser(
);
}
const supportsMultiRole = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.FullRbac]
);
if (!supportsMultiRole && uniqueRoleIds.length > 1) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Multiple roles per user require a subscription or license that includes full RBAC."
)
);
}
const orgRoles = await db
.select({ roleId: roles.roleId })
.from(roles)
.where(
and(
eq(roles.orgId, orgId),
inArray(roles.roleId, uniqueRoleIds)
)
);
if (orgRoles.length !== uniqueRoleIds.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid role ID or role does not belong to this organization"
)
);
}
const [org] = await db
.select()
.from(orgs)
@@ -221,12 +265,16 @@ export async function createOrgUser(
);
}
await assignUserToOrg(org, {
orgId,
userId: existingUser.userId,
roleId: role.roleId,
autoProvisioned: false
}, trx);
await assignUserToOrg(
org,
{
orgId,
userId: existingUser.userId,
autoProvisioned: false,
},
uniqueRoleIds,
trx
);
} else {
userId = generateId(15);
@@ -244,12 +292,16 @@ export async function createOrgUser(
})
.returning();
await assignUserToOrg(org, {
orgId,
userId: newUser.userId,
roleId: role.roleId,
autoProvisioned: false
}, trx);
await assignUserToOrg(
org,
{
orgId,
userId: newUser.userId,
autoProvisioned: false,
},
uniqueRoleIds,
trx
);
}
await calculateUserClientsForOrgs(userId, trx);

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idp, idpOidcConfig } from "@server/db";
import { roles, userOrgs, users } from "@server/db";
import { roles, userOrgRoles, userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -12,7 +12,7 @@ import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { OpenAPITags, registry } from "@server/openApi";
export async function queryUser(orgId: string, userId: string) {
const [user] = await db
const [userRow] = await db
.select({
orgId: userOrgs.orgId,
userId: users.userId,
@@ -20,10 +20,7 @@ export async function queryUser(orgId: string, userId: string) {
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner,
isAdmin: roles.isAdmin,
twoFactorEnabled: users.twoFactorEnabled,
autoProvisioned: userOrgs.autoProvisioned,
idpId: users.idpId,
@@ -33,13 +30,40 @@ export async function queryUser(orgId: string, userId: string) {
idpAutoProvision: idp.autoProvision
})
.from(userOrgs)
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(users, eq(userOrgs.userId, users.userId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId))
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
.limit(1);
return user;
if (!userRow) return undefined;
const roleRows = await db
.select({
roleId: userOrgRoles.roleId,
roleName: roles.name,
isAdmin: roles.isAdmin
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.userId, userId),
eq(userOrgRoles.orgId, orgId)
)
);
const isAdmin = roleRows.some((r) => r.isAdmin);
return {
...userRow,
isAdmin,
roleIds: roleRows.map((r) => r.roleId),
roles: roleRows.map((r) => ({
roleId: r.roleId,
name: r.roleName ?? ""
}))
};
}
export type GetOrgUserResponse = NonNullable<

View File

@@ -1,7 +1,8 @@
export * from "./getUser";
export * from "./removeUserOrg";
export * from "./listUsers";
export * from "./addUserRole";
export * from "./types";
export * from "./addUserRoleLegacy";
export * from "./inviteUser";
export * from "./acceptInvite";
export * from "./getOrgUser";

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { orgs, roles, userInvites, userOrgs, users } from "@server/db";
import { and, eq } from "drizzle-orm";
import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -18,22 +18,44 @@ import { OpenAPITags, registry } from "@server/openApi";
import { UserType } from "@server/types/UserTypes";
import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
import cache from "#dynamic/lib/cache";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const inviteUserParamsSchema = z.strictObject({
orgId: z.string()
});
const inviteUserBodySchema = z.strictObject({
email: z.email().toLowerCase(),
roleId: z.number(),
validHours: z.number().gt(0).lte(168),
sendEmail: z.boolean().optional(),
regenerate: z.boolean().optional()
});
const inviteUserBodySchema = z
.strictObject({
email: z.email().toLowerCase(),
roleIds: z.array(z.number().int().positive()).min(1).optional(),
roleId: z.number().int().positive().optional(),
validHours: z.number().gt(0).lte(168),
sendEmail: z.boolean().optional(),
regenerate: z.boolean().optional()
})
.refine(
(d) =>
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
{ message: "roleIds or roleId is required", path: ["roleIds"] }
)
.transform((data) => ({
email: data.email,
validHours: data.validHours,
sendEmail: data.sendEmail,
regenerate: data.regenerate,
roleIds: [
...new Set(
data.roleIds && data.roleIds.length > 0
? data.roleIds
: [data.roleId!]
)
]
}));
export type InviteUserBody = z.infer<typeof inviteUserBodySchema>;
export type InviteUserBody = z.input<typeof inviteUserBodySchema>;
export type InviteUserResponse = {
inviteLink: string;
@@ -88,7 +110,7 @@ export async function inviteUser(
const {
email,
validHours,
roleId,
roleIds: uniqueRoleIds,
sendEmail: doEmail,
regenerate
} = parsedBody.data;
@@ -105,14 +127,30 @@ export async function inviteUser(
);
}
// Validate that the roleId belongs to the target organization
const [role] = await db
.select()
.from(roles)
.where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId)))
.limit(1);
const supportsMultiRole = await isLicensedOrSubscribed(
orgId,
tierMatrix[TierFeature.FullRbac]
);
if (!supportsMultiRole && uniqueRoleIds.length > 1) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Multiple roles per user require a subscription or license that includes full RBAC."
)
);
}
if (!role) {
const orgRoles = await db
.select({ roleId: roles.roleId })
.from(roles)
.where(
and(
eq(roles.orgId, orgId),
inArray(roles.roleId, uniqueRoleIds)
)
);
if (orgRoles.length !== uniqueRoleIds.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -191,7 +229,8 @@ export async function inviteUser(
}
if (existingInvite.length) {
const attempts = (await cache.get<number>(email)) || 0;
const attempts =
(await cache.get<number>("regenerateInvite:" + email)) || 0;
if (attempts >= 3) {
return next(
createHttpError(
@@ -273,9 +312,11 @@ export async function inviteUser(
orgId,
email,
expiresAt,
tokenHash,
roleId
tokenHash
});
await trx.insert(userInviteRoles).values(
uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))
);
});
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;

View File

@@ -1,11 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { userInvites, roles } from "@server/db";
import { userInvites, userInviteRoles, roles } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql } from "drizzle-orm";
import { sql, eq, and, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@@ -29,24 +29,66 @@ const listInvitationsQuerySchema = z.strictObject({
.pipe(z.int().nonnegative())
});
async function queryInvitations(orgId: string, limit: number, offset: number) {
return await db
export type InvitationListRow = {
inviteId: string;
email: string;
expiresAt: number;
roles: { roleId: number; roleName: string | null }[];
};
async function queryInvitations(
orgId: string,
limit: number,
offset: number
): Promise<InvitationListRow[]> {
const inviteRows = await db
.select({
inviteId: userInvites.inviteId,
email: userInvites.email,
expiresAt: userInvites.expiresAt,
roleId: userInvites.roleId,
roleName: roles.name
expiresAt: userInvites.expiresAt
})
.from(userInvites)
.leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`)
.where(sql`${userInvites.orgId} = ${orgId}`)
.where(eq(userInvites.orgId, orgId))
.limit(limit)
.offset(offset);
if (inviteRows.length === 0) {
return [];
}
const inviteIds = inviteRows.map((r) => r.inviteId);
const roleRows = await db
.select({
inviteId: userInviteRoles.inviteId,
roleId: userInviteRoles.roleId,
roleName: roles.name
})
.from(userInviteRoles)
.innerJoin(roles, eq(userInviteRoles.roleId, roles.roleId))
.where(
and(eq(roles.orgId, orgId), inArray(userInviteRoles.inviteId, inviteIds))
);
const rolesByInvite = new Map<
string,
{ roleId: number; roleName: string | null }[]
>();
for (const row of roleRows) {
const list = rolesByInvite.get(row.inviteId) ?? [];
list.push({ roleId: row.roleId, roleName: row.roleName });
rolesByInvite.set(row.inviteId, list);
}
return inviteRows.map((inv) => ({
inviteId: inv.inviteId,
email: inv.email,
expiresAt: inv.expiresAt,
roles: rolesByInvite.get(inv.inviteId) ?? []
}));
}
export type ListInvitationsResponse = {
invitations: NonNullable<Awaited<ReturnType<typeof queryInvitations>>>;
invitations: InvitationListRow[];
pagination: { total: number; limit: number; offset: number };
};
@@ -95,7 +137,7 @@ export async function listInvitations(
const [{ count }] = await db
.select({ count: sql<number>`count(*)` })
.from(userInvites)
.where(sql`${userInvites.orgId} = ${orgId}`);
.where(eq(userInvites.orgId, orgId));
return response<ListInvitationsResponse>(res, {
data: {

View File

@@ -1,15 +1,14 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, idpOidcConfig } from "@server/db";
import { idp, roles, userOrgs, users } from "@server/db";
import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { and, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq } from "drizzle-orm";
const listUsersParamsSchema = z.strictObject({
orgId: z.string()
@@ -31,7 +30,7 @@ const listUsersSchema = z.strictObject({
});
async function queryUsers(orgId: string, limit: number, offset: number) {
return await db
const rows = await db
.select({
id: users.userId,
email: users.email,
@@ -41,8 +40,6 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
username: users.username,
name: users.name,
type: users.type,
roleId: userOrgs.roleId,
roleName: roles.name,
isOwner: userOrgs.isOwner,
idpName: idp.name,
idpId: users.idpId,
@@ -52,12 +49,48 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
})
.from(users)
.leftJoin(userOrgs, eq(users.userId, userOrgs.userId))
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
.leftJoin(idp, eq(users.idpId, idp.idpId))
.leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId))
.where(eq(userOrgs.orgId, orgId))
.limit(limit)
.offset(offset);
const userIds = rows.map((r) => r.id);
const roleRows =
userIds.length === 0
? []
: await db
.select({
userId: userOrgRoles.userId,
roleId: userOrgRoles.roleId,
roleName: roles.name
})
.from(userOrgRoles)
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(
and(
eq(userOrgRoles.orgId, orgId),
inArray(userOrgRoles.userId, userIds)
)
);
const rolesByUser = new Map<
string,
{ roleId: number; roleName: string }[]
>();
for (const r of roleRows) {
const list = rolesByUser.get(r.userId) ?? [];
list.push({ roleId: r.roleId, roleName: r.roleName ?? "" });
rolesByUser.set(r.userId, list);
}
return rows.map((row) => {
const userRoles = rolesByUser.get(row.id) ?? [];
return {
...row,
roles: userRoles
};
});
}
export type ListUsersResponse = {

View File

@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express";
import { db, Olm, olms, orgs, userOrgs } from "@server/db";
import { db, Olm, olms, orgs, userOrgRoles, userOrgs } from "@server/db";
import { idp, users } from "@server/db";
import { and, eq } from "drizzle-orm";
import response from "@server/lib/response";
@@ -84,16 +84,31 @@ export async function myDevice(
.from(olms)
.where(and(eq(olms.userId, userId), eq(olms.olmId, olmId)));
const userOrganizations = await db
const userOrgRows = await db
.select({
orgId: userOrgs.orgId,
orgName: orgs.name,
roleId: userOrgs.roleId
orgName: orgs.name
})
.from(userOrgs)
.where(eq(userOrgs.userId, userId))
.innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId));
const roleRows = await db
.select({
orgId: userOrgRoles.orgId,
roleId: userOrgRoles.roleId
})
.from(userOrgRoles)
.where(eq(userOrgRoles.userId, userId));
const roleByOrg = new Map(
roleRows.map((r) => [r.orgId, r.roleId])
);
const userOrganizations = userOrgRows.map((row) => ({
...row,
roleId: roleByOrg.get(row.orgId) ?? 0
}));
return response<MyDeviceResponse>(res, {
data: {
user,

View File

@@ -0,0 +1,18 @@
import type { UserOrg } from "@server/db";
export type AddUserRoleResponse = {
userId: string;
roleId: number;
};
/** Legacy POST /role/:roleId/add/:userId response shape (membership + effective role). */
export type AddUserRoleLegacyResponse = UserOrg & { roleId: number };
export type SetUserOrgRolesParams = {
orgId: string;
userId: string;
};
export type SetUserOrgRolesBody = {
roleIds: number[];
};