mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-30 22:46:40 +00:00
Merge branch 'multi-role' into dev
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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(", ")
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -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);
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
18
server/routers/user/types.ts
Normal file
18
server/routers/user/types.ts
Normal 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[];
|
||||
};
|
||||
Reference in New Issue
Block a user