Merge pull request #1705 from Infisical/groups-phase-2b

Groups Phase 2A (Pending Group Additions)
This commit is contained in:
BlackMagiq
2024-04-22 14:25:36 -07:00
committed by GitHub
20 changed files with 1191 additions and 482 deletions
@@ -0,0 +1,15 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.UserGroupMembership, (t) => {
t.boolean("isPending").notNullable().defaultTo(false);
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable(TableName.UserGroupMembership, (t) => {
t.dropColumn("isPending");
});
}
@@ -12,7 +12,8 @@ export const UserGroupMembershipSchema = z.object({
userId: z.string().uuid(), userId: z.string().uuid(),
groupId: z.string().uuid(), groupId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
isPending: z.boolean().default(false)
}); });
export type TUserGroupMembership = z.infer<typeof UserGroupMembershipSchema>; export type TUserGroupMembership = z.infer<typeof UserGroupMembershipSchema>;
+25 -8
View File
@@ -289,14 +289,28 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
body: z.object({ body: z.object({
schemas: z.array(z.string()), schemas: z.array(z.string()),
displayName: z.string().trim(), displayName: z.string().trim(),
members: z.array(z.any()).length(0).optional() // okta-specific members: z
.array(
z.object({
value: z.string(),
display: z.string()
})
)
.optional() // okta-specific
}), }),
response: { response: {
200: z.object({ 200: z.object({
schemas: z.array(z.string()), schemas: z.array(z.string()),
id: z.string().trim(), id: z.string().trim(),
displayName: z.string().trim(), displayName: z.string().trim(),
members: z.array(z.any()).length(0), members: z
.array(
z.object({
value: z.string(),
display: z.string()
})
)
.optional(),
meta: z.object({ meta: z.object({
resourceType: z.string().trim() resourceType: z.string().trim()
}) })
@@ -306,8 +320,8 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => { handler: async (req) => {
const group = await req.server.services.scim.createScimGroup({ const group = await req.server.services.scim.createScimGroup({
displayName: req.body.displayName, orgId: req.permission.orgId,
orgId: req.permission.orgId ...req.body
}); });
return group; return group;
@@ -400,7 +414,12 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
schemas: z.array(z.string()), schemas: z.array(z.string()),
id: z.string().trim(), id: z.string().trim(),
displayName: z.string().trim(), displayName: z.string().trim(),
members: z.array(z.any()).length(0) members: z.array(
z.object({
value: z.string(), // infisical userId
display: z.string()
})
) // note: is this where members are added to group?
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -424,7 +443,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
const group = await req.server.services.scim.updateScimGroupNamePut({ const group = await req.server.services.scim.updateScimGroupNamePut({
groupId: req.params.groupId, groupId: req.params.groupId,
orgId: req.permission.orgId, orgId: req.permission.orgId,
displayName: req.body.displayName ...req.body
}); });
return group; return group;
@@ -482,8 +501,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
handler: async (req) => { handler: async (req) => {
// console.log("PATCH /Groups/:groupId req.body: ", req.body);
// console.log("PATCH /Groups/:groupId req.body: ", req.body.Operations[0]);
const group = await req.server.services.scim.updateScimGroupNamePatch({ const group = await req.server.services.scim.updateScimGroupNamePatch({
groupId: req.params.groupId, groupId: req.params.groupId,
orgId: req.permission.orgId, orgId: req.permission.orgId,
@@ -59,32 +59,6 @@ export const groupDALFactory = (db: TDbClient) => {
} }
}; };
const countAllGroupMembers = async ({ orgId, groupId }: { orgId: string; groupId: string }) => {
try {
interface CountResult {
count: string;
}
const doc = await db<CountResult>(TableName.OrgMembership)
.where(`${TableName.OrgMembership}.orgId`, orgId)
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.UserGroupMembership, function () {
this.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn(
`${TableName.UserGroupMembership}.groupId`,
"=",
db.raw("?", [groupId])
);
})
.where({ isGhost: false })
.count(`${TableName.Users}.id`)
.first();
return parseInt((doc?.count as string) || "0", 10);
} catch (err) {
throw new DatabaseError({ error: err, name: "Count all group members" });
}
};
// special query // special query
const findAllGroupMembers = async ({ const findAllGroupMembers = async ({
orgId, orgId,
@@ -150,7 +124,6 @@ export const groupDALFactory = (db: TDbClient) => {
return { return {
findGroups, findGroups,
findByOrgId, findByOrgId,
countAllGroupMembers,
findAllGroupMembers, findAllGroupMembers,
...groupOrm ...groupOrm
}; };
+454
View File
@@ -0,0 +1,454 @@
import { Knex } from "knex";
import { SecretKeyEncoding, TUsers } from "@app/db/schemas";
import { decryptAsymmetric, encryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { BadRequestError, ScimRequestError } from "@app/lib/errors";
import {
TAddUsersToGroup,
TAddUsersToGroupByUserIds,
TConvertPendingGroupAdditionsToGroupMemberships,
TRemoveUsersFromGroupByUserIds
} from "./group-types";
const addAcceptedUsersToGroup = async ({
userIds,
group,
userGroupMembershipDAL,
userDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
}: TAddUsersToGroup) => {
console.log("addAcceptedUsersToGroup args: ", {
userIds,
group
});
const users = await userDAL.findUserEncKeyByUserIdsBatch(
{
userIds
},
tx
);
await userGroupMembershipDAL.insertMany(
users.map((user) => ({
userId: user.userId,
groupId: group.id,
isPending: false
})),
tx
);
// check which projects the group is part of
const projectIds = Array.from(
new Set(
(
await groupProjectDAL.find(
{
groupId: group.id
},
{ tx }
)
).map((gp) => gp.projectId)
)
);
const keys = await projectKeyDAL.find(
{
$in: {
projectId: projectIds,
receiverId: users.map((u) => u.id)
}
},
{ tx }
);
const userKeysSet = new Set(keys.map((k) => `${k.projectId}-${k.receiverId}`));
for await (const projectId of projectIds) {
const usersToAddProjectKeyFor = users.filter((u) => !userKeysSet.has(`${projectId}-${u.userId}`));
if (usersToAddProjectKeyFor.length) {
// there are users who need to be shared keys
// process adding bulk users to projects for each project individually
const ghostUser = await projectDAL.findProjectGhostUser(projectId, tx);
if (!ghostUser) {
throw new BadRequestError({
message: "Failed to find sudo user"
});
}
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId, tx);
if (!ghostUserLatestKey) {
throw new BadRequestError({
message: "Failed to find sudo user latest key"
});
}
const bot = await projectBotDAL.findOne({ projectId }, tx);
if (!bot) {
throw new BadRequestError({
message: "Failed to find bot"
});
}
const botPrivateKey = infisicalSymmetricDecrypt({
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey
});
const plaintextProjectKey = decryptAsymmetric({
ciphertext: ghostUserLatestKey.encryptedKey,
nonce: ghostUserLatestKey.nonce,
publicKey: ghostUserLatestKey.sender.publicKey,
privateKey: botPrivateKey
});
const projectKeysToAdd = usersToAddProjectKeyFor.map((user) => {
const { ciphertext: encryptedKey, nonce } = encryptAsymmetric(
plaintextProjectKey,
user.publicKey,
botPrivateKey
);
return {
encryptedKey,
nonce,
senderId: ghostUser.id,
receiverId: user.userId,
projectId
};
});
await projectKeyDAL.insertMany(projectKeysToAdd, tx);
}
}
};
/**
* Add users with user ids [userIds] to group [group].
* - Users may or may not have finished completing their accounts; this function will
* handle both adding users to groups directly and via pending group additions.
* @param {group} group - group to add user(s) to
* @param {string[]} userIds - id(s) of user(s) to add to group
*/
export const addUsersToGroupByUserIds = async ({
group,
userIds,
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx: outerTx
}: TAddUsersToGroupByUserIds) => {
const processAddition = async (tx: Knex) => {
const foundMembers = await userDAL.find(
{
$in: {
id: userIds
}
},
{ tx }
);
const foundMembersIdsSet = new Set(foundMembers.map((member) => member.id));
const isCompleteMatch = userIds.every((userId) => foundMembersIdsSet.has(userId));
if (!isCompleteMatch) {
throw new ScimRequestError({
detail: "Members not found",
status: 404
});
}
// check if user(s) group membership(s) already exists
const existingUserGroupMemberships = await userGroupMembershipDAL.find(
{
groupId: group.id,
$in: {
userId: userIds
}
},
{ tx }
);
if (existingUserGroupMemberships.length) {
throw new BadRequestError({
message: `User(s) are already part of the group ${group.slug}`
});
}
// check if all user(s) are part of the organization
const existingUserOrgMemberships = await orgDAL.findMembership(
{
orgId: group.orgId,
$in: {
userId: userIds
}
},
{ tx }
);
const existingUserOrgMembershipsUserIdsSet = new Set(existingUserOrgMemberships.map((u) => u.userId));
userIds.forEach((userId) => {
if (!existingUserOrgMembershipsUserIdsSet.has(userId))
throw new BadRequestError({
message: `User with id ${userId} is not part of the organization`
});
});
const membersToAddToGroupNonPending: TUsers[] = [];
const membersToAddToGroupPending: TUsers[] = [];
foundMembers.forEach((member) => {
if (member.isAccepted) {
// add accepted member to group
membersToAddToGroupNonPending.push(member);
} else {
// add incomplete member to pending group addition
membersToAddToGroupPending.push(member);
}
});
if (membersToAddToGroupNonPending.length) {
await addAcceptedUsersToGroup({
userIds: membersToAddToGroupNonPending.map((member) => member.id),
group,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
});
}
if (membersToAddToGroupPending.length) {
await userGroupMembershipDAL.insertMany(
membersToAddToGroupPending.map((member) => ({
userId: member.id,
groupId: group.id,
isPending: true
})),
tx
);
}
return membersToAddToGroupNonPending.concat(membersToAddToGroupPending);
};
if (outerTx) {
return processAddition(outerTx);
}
return userDAL.transaction(async (tx) => {
return processAddition(tx);
});
};
/**
* Remove users with user ids [userIds] from group [group].
* - Users may be part of the group (non-pending + pending);
* this function will handle both cases.
* @param {group} group - group to remove user(s) from
* @param {string[]} userIds - id(s) of user(s) to remove from group
*/
export const removeUsersFromGroupByUserIds = async ({
group,
userIds,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
tx: outerTx
}: TRemoveUsersFromGroupByUserIds) => {
const processRemoval = async (tx: Knex) => {
const foundMembers = await userDAL.find({
$in: {
id: userIds
}
});
const foundMembersIdsSet = new Set(foundMembers.map((member) => member.id));
const isCompleteMatch = userIds.every((userId) => foundMembersIdsSet.has(userId));
if (!isCompleteMatch) {
throw new ScimRequestError({
detail: "Members not found",
status: 404
});
}
// check if user group membership already exists
const existingUserGroupMemberships = await userGroupMembershipDAL.find(
{
groupId: group.id,
$in: {
userId: userIds
}
},
{ tx }
);
const existingUserGroupMembershipsUserIdsSet = new Set(existingUserGroupMemberships.map((u) => u.userId));
userIds.forEach((userId) => {
if (!existingUserGroupMembershipsUserIdsSet.has(userId))
throw new BadRequestError({
message: `User(s) are not part of the group ${group.slug}`
});
});
const membersToRemoveFromGroupNonPending: TUsers[] = [];
const membersToRemoveFromGroupPending: TUsers[] = [];
foundMembers.forEach((member) => {
if (member.isAccepted) {
// remove accepted member from group
membersToRemoveFromGroupNonPending.push(member);
} else {
// remove incomplete member from pending group addition
membersToRemoveFromGroupPending.push(member);
}
});
if (membersToRemoveFromGroupNonPending.length) {
// check which projects the group is part of
const projectIds = Array.from(
new Set(
(
await groupProjectDAL.find(
{
groupId: group.id
},
{ tx }
)
).map((gp) => gp.projectId)
)
);
// TODO: this part can be optimized
for await (const userId of userIds) {
const t = await userGroupMembershipDAL.filterProjectsByUserMembership(userId, group.id, projectIds, tx);
const projectsToDeleteKeyFor = projectIds.filter((p) => !t.has(p));
if (projectsToDeleteKeyFor.length) {
await projectKeyDAL.delete(
{
receiverId: userId,
$in: {
projectId: projectsToDeleteKeyFor
}
},
tx
);
}
await userGroupMembershipDAL.delete(
{
groupId: group.id,
userId
},
tx
);
}
}
if (membersToRemoveFromGroupPending.length) {
await userGroupMembershipDAL.delete({
groupId: group.id,
$in: {
userId: membersToRemoveFromGroupPending.map((member) => member.id)
}
});
}
return membersToRemoveFromGroupNonPending.concat(membersToRemoveFromGroupPending);
};
if (outerTx) {
return processRemoval(outerTx);
}
return userDAL.transaction(async (tx) => {
return processRemoval(tx);
});
};
/**
* Convert pending group additions for users with ids [userIds] to group memberships.
* @param {string[]} userIds - id(s) of user(s) to try to convert pending group additions to group memberships
*/
export const convertPendingGroupAdditionsToGroupMemberships = async ({
userIds,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx: outerTx
}: TConvertPendingGroupAdditionsToGroupMemberships) => {
const processConversion = async (tx: Knex) => {
const users = await userDAL.find(
{
$in: {
id: userIds
}
},
{ tx }
);
const usersUserIdsSet = new Set(users.map((u) => u.id));
userIds.forEach((userId) => {
if (!usersUserIdsSet.has(userId)) {
throw new BadRequestError({
message: `Failed to find user with id ${userId}`
});
}
});
users.forEach((user) => {
if (!user.isAccepted) {
throw new BadRequestError({
message: `Failed to convert pending group additions to group memberships for user ${user.username} because they have not confirmed their account`
});
}
});
const pendingGroupAdditions = await userGroupMembershipDAL.deletePendingUserGroupMembershipsByUserIds(userIds, tx);
for await (const pendingGroupAddition of pendingGroupAdditions) {
await addAcceptedUsersToGroup({
userIds: [pendingGroupAddition.user.id],
group: pendingGroupAddition.group,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
});
}
};
if (outerTx) {
return processConversion(outerTx);
}
return userDAL.transaction(async (tx) => {
await processConversion(tx);
});
};
+40 -167
View File
@@ -1,22 +1,22 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import { OrgMembershipRole, SecretKeyEncoding, TOrgRoles } from "@app/db/schemas"; import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { decryptAsymmetric, encryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TGroupProjectDALFactory } from "../../../services/group-project/group-project-dal";
import { TOrgDALFactory } from "../../../services/org/org-dal";
import { TProjectDALFactory } from "../../../services/project/project-dal";
import { TProjectBotDALFactory } from "../../../services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "../../../services/project-key/project-key-dal";
import { TUserDALFactory } from "../../../services/user/user-dal";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { TGroupDALFactory } from "./group-dal"; import { TGroupDALFactory } from "./group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns";
import { import {
TAddUserToGroupDTO, TAddUserToGroupDTO,
TCreateGroupDTO, TCreateGroupDTO,
@@ -28,20 +28,17 @@ import {
import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal";
type TGroupServiceFactoryDep = { type TGroupServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "findOne" | "findUserEncKeyByUsername">; userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findOne">;
groupDAL: Pick< groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "update" | "delete" | "findAllGroupMembers">;
TGroupDALFactory,
"create" | "findOne" | "update" | "delete" | "findAllGroupMembers" | "countAllGroupMembers"
>;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">; groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
orgDAL: Pick<TOrgDALFactory, "findMembership">; orgDAL: Pick<TOrgDALFactory, "findMembership" | "countAllOrgMembers">;
userGroupMembershipDAL: Pick< userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory, TUserGroupMembershipDALFactory,
"findOne" | "create" | "delete" | "filterProjectsByUserMembership" "findOne" | "delete" | "filterProjectsByUserMembership" | "transaction" | "insertMany" | "find"
>; >;
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">; projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">; projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "create" | "delete" | "findLatestProjectKey">; projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete" | "findLatestProjectKey" | "insertMany">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
}; };
@@ -227,12 +224,9 @@ export const groupServiceFactory = ({
username username
}); });
const totalCount = await groupDAL.countAllGroupMembers({ const count = await orgDAL.countAllOrgMembers(group.orgId);
orgId: group.orgId,
groupId: group.id
});
return { users, totalCount }; return { users, totalCount: count };
}; };
const addUserToGroup = async ({ const addUserToGroup = async ({
@@ -272,111 +266,22 @@ export const groupServiceFactory = ({
if (!hasRequiredPriviledges) if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" });
// get user with username const user = await userDAL.findOne({ username });
const user = await userDAL.findUserEncKeyByUsername({ if (!user) throw new BadRequestError({ message: `Failed to find user with username ${username}` });
username
const users = await addUsersToGroupByUserIds({
group,
userIds: [user.id],
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL
}); });
if (!user) return users[0];
throw new BadRequestError({
message: `Failed to find user with username ${username}`
});
// check if user group membership already exists
const existingUserGroupMembership = await userGroupMembershipDAL.findOne({
groupId: group.id,
userId: user.userId
});
if (existingUserGroupMembership)
throw new BadRequestError({
message: `User ${username} is already part of the group ${groupSlug}`
});
// check if user is even part of the organization
const existingUserOrgMembership = await orgDAL.findMembership({
userId: user.userId,
orgId: actorOrgId
});
if (!existingUserOrgMembership)
throw new BadRequestError({
message: `User ${username} is not part of the organization`
});
await userGroupMembershipDAL.create({
userId: user.userId,
groupId: group.id
});
// check which projects the group is part of
const projectIds = (
await groupProjectDAL.find({
groupId: group.id
})
).map((gp) => gp.projectId);
const keys = await projectKeyDAL.find({
receiverId: user.userId,
$in: {
projectId: projectIds
}
});
const keysSet = new Set(keys.map((k) => k.projectId));
const projectsToAddKeyFor = projectIds.filter((p) => !keysSet.has(p));
for await (const projectId of projectsToAddKeyFor) {
const ghostUser = await projectDAL.findProjectGhostUser(projectId);
if (!ghostUser) {
throw new BadRequestError({
message: "Failed to find sudo user"
});
}
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId);
if (!ghostUserLatestKey) {
throw new BadRequestError({
message: "Failed to find sudo user latest key"
});
}
const bot = await projectBotDAL.findOne({ projectId });
if (!bot) {
throw new BadRequestError({
message: "Failed to find bot"
});
}
const botPrivateKey = infisicalSymmetricDecrypt({
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey
});
const plaintextProjectKey = decryptAsymmetric({
ciphertext: ghostUserLatestKey.encryptedKey,
nonce: ghostUserLatestKey.nonce,
publicKey: ghostUserLatestKey.sender.publicKey,
privateKey: botPrivateKey
});
const { ciphertext: encryptedKey, nonce } = encryptAsymmetric(plaintextProjectKey, user.publicKey, botPrivateKey);
await projectKeyDAL.create({
encryptedKey,
nonce,
senderId: ghostUser.id,
receiverId: user.userId,
projectId
});
}
return user;
}; };
const removeUserFromGroup = async ({ const removeUserFromGroup = async ({
@@ -416,51 +321,19 @@ export const groupServiceFactory = ({
if (!hasRequiredPriviledges) if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" });
const user = await userDAL.findOne({ const user = await userDAL.findOne({ username });
username if (!user) throw new BadRequestError({ message: `Failed to find user with username ${username}` });
const users = await removeUsersFromGroupByUserIds({
group,
userIds: [user.id],
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL
}); });
if (!user) return users[0];
throw new BadRequestError({
message: `Failed to find user with username ${username}`
});
// check if user group membership already exists
const existingUserGroupMembership = await userGroupMembershipDAL.findOne({
groupId: group.id,
userId: user.id
});
if (!existingUserGroupMembership)
throw new BadRequestError({
message: `User ${username} is not part of the group ${groupSlug}`
});
const projectIds = (
await groupProjectDAL.find({
groupId: group.id
})
).map((gp) => gp.projectId);
const t = await userGroupMembershipDAL.filterProjectsByUserMembership(user.id, group.id, projectIds);
const projectsToDeleteKeyFor = projectIds.filter((p) => !t.has(p));
if (projectsToDeleteKeyFor.length) {
await projectKeyDAL.delete({
receiverId: user.id,
$in: {
projectId: projectsToDeleteKeyFor
}
});
}
await userGroupMembershipDAL.delete({
groupId: group.id,
userId: user.id
});
return user;
}; };
return { return {
@@ -1,4 +1,14 @@
import { Knex } from "knex";
import { TGroups } from "@app/db/schemas";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { TGenericPermission } from "@app/lib/types"; import { TGenericPermission } from "@app/lib/types";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
export type TCreateGroupDTO = { export type TCreateGroupDTO = {
name: string; name: string;
@@ -35,3 +45,54 @@ export type TRemoveUserFromGroupDTO = {
groupSlug: string; groupSlug: string;
username: string; username: string;
} & TGenericPermission; } & TGenericPermission;
// group fns types
export type TAddUsersToGroup = {
userIds: string[];
group: TGroups;
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserIdsBatch">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "find" | "transaction" | "insertMany">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany">;
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
tx: Knex;
};
export type TAddUsersToGroupByUserIds = {
group: TGroups;
userIds: string[];
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "find" | "transaction" | "insertMany">;
orgDAL: Pick<TOrgDALFactory, "findMembership">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany">;
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
tx?: Knex;
};
export type TRemoveUsersFromGroupByUserIds = {
group: TGroups;
userIds: string[];
userDAL: Pick<TUserDALFactory, "find" | "transaction">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "find" | "filterProjectsByUserMembership" | "delete">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "delete">;
tx?: Knex;
};
export type TConvertPendingGroupAdditionsToGroupMemberships = {
userIds: string[];
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserIdsBatch" | "transaction" | "find" | "findById">;
userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"find" | "transaction" | "insertMany" | "deletePendingUserGroupMembershipsByUserIds"
>;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany">;
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
tx?: Knex;
};
@@ -1,3 +1,5 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
@@ -14,24 +16,28 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
* - The user is a member of a group that is a member of the project, excluding projects that they are part of * - The user is a member of a group that is a member of the project, excluding projects that they are part of
* through the group with id [groupId]. * through the group with id [groupId].
*/ */
const filterProjectsByUserMembership = async (userId: string, groupId: string, projectIds: string[]) => { const filterProjectsByUserMembership = async (userId: string, groupId: string, projectIds: string[], tx?: Knex) => {
const userProjectMemberships: string[] = await db(TableName.ProjectMembership) try {
.where(`${TableName.ProjectMembership}.userId`, userId) const userProjectMemberships: string[] = await (tx || db)(TableName.ProjectMembership)
.whereIn(`${TableName.ProjectMembership}.projectId`, projectIds) .where(`${TableName.ProjectMembership}.userId`, userId)
.pluck(`${TableName.ProjectMembership}.projectId`); .whereIn(`${TableName.ProjectMembership}.projectId`, projectIds)
.pluck(`${TableName.ProjectMembership}.projectId`);
const userGroupMemberships: string[] = await db(TableName.UserGroupMembership) const userGroupMemberships: string[] = await (tx || db)(TableName.UserGroupMembership)
.where(`${TableName.UserGroupMembership}.userId`, userId) .where(`${TableName.UserGroupMembership}.userId`, userId)
.whereNot(`${TableName.UserGroupMembership}.groupId`, groupId) .whereNot(`${TableName.UserGroupMembership}.groupId`, groupId)
.join( .join(
TableName.GroupProjectMembership, TableName.GroupProjectMembership,
`${TableName.UserGroupMembership}.groupId`, `${TableName.UserGroupMembership}.groupId`,
`${TableName.GroupProjectMembership}.groupId` `${TableName.GroupProjectMembership}.groupId`
) )
.whereIn(`${TableName.GroupProjectMembership}.projectId`, projectIds) .whereIn(`${TableName.GroupProjectMembership}.projectId`, projectIds)
.pluck(`${TableName.GroupProjectMembership}.projectId`); .pluck(`${TableName.GroupProjectMembership}.projectId`);
return new Set(userProjectMemberships.concat(userGroupMemberships)); return new Set(userProjectMemberships.concat(userGroupMemberships));
} catch (error) {
throw new DatabaseError({ error, name: "Filter projects by user membership" });
}
}; };
// special query // special query
@@ -45,7 +51,7 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
) )
.join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`)
.where(`${TableName.GroupProjectMembership}.projectId`, projectId) .where(`${TableName.GroupProjectMembership}.projectId`, projectId)
.whereIn(`${TableName.Users}.username`, usernames) // TODO: pluck usernames .whereIn(`${TableName.Users}.username`, usernames)
.pluck(`${TableName.Users}.id`); .pluck(`${TableName.Users}.id`);
return usernameDocs; return usernameDocs;
@@ -55,7 +61,7 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
}; };
/** /**
* Return list of users that are part of the group with id [groupId] * Return list of completed/accepted users that are part of the group with id [groupId]
* that have not yet been added individually to project with id [projectId]. * that have not yet been added individually to project with id [projectId].
* *
* Note: Filters out users that are part of other groups in the project. * Note: Filters out users that are part of other groups in the project.
@@ -63,18 +69,19 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
* @param projectId * @param projectId
* @returns * @returns
*/ */
const findGroupMembersNotInProject = async (groupId: string, projectId: string) => { const findGroupMembersNotInProject = async (groupId: string, projectId: string, tx?: Knex) => {
try { try {
// get list of groups in the project with id [projectId] // get list of groups in the project with id [projectId]
// that that are not the group with id [groupId] // that that are not the group with id [groupId]
const groups: string[] = await db(TableName.GroupProjectMembership) const groups: string[] = await (tx || db)(TableName.GroupProjectMembership)
.where(`${TableName.GroupProjectMembership}.projectId`, projectId) .where(`${TableName.GroupProjectMembership}.projectId`, projectId)
.whereNot(`${TableName.GroupProjectMembership}.groupId`, groupId) .whereNot(`${TableName.GroupProjectMembership}.groupId`, groupId)
.pluck(`${TableName.GroupProjectMembership}.groupId`); .pluck(`${TableName.GroupProjectMembership}.groupId`);
// main query // main query
const members = await db(TableName.UserGroupMembership) const members = await (tx || db)(TableName.UserGroupMembership)
.where(`${TableName.UserGroupMembership}.groupId`, groupId) .where(`${TableName.UserGroupMembership}.groupId`, groupId)
.where(`${TableName.UserGroupMembership}.isPending`, false)
.join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.ProjectMembership, function () { .leftJoin(TableName.ProjectMembership, function () {
this.on(`${TableName.Users}.id`, "=", `${TableName.ProjectMembership}.userId`).andOn( this.on(`${TableName.Users}.id`, "=", `${TableName.ProjectMembership}.userId`).andOn(
@@ -116,10 +123,49 @@ export const userGroupMembershipDALFactory = (db: TDbClient) => {
} }
}; };
const deletePendingUserGroupMembershipsByUserIds = async (userIds: string[], tx?: Knex) => {
try {
const members = await (tx || db)(TableName.UserGroupMembership)
.whereIn(`${TableName.UserGroupMembership}.userId`, userIds)
.where(`${TableName.UserGroupMembership}.isPending`, true)
.join(TableName.Groups, `${TableName.UserGroupMembership}.groupId`, `${TableName.Groups}.id`)
.join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`);
await userGroupMembershipOrm.delete(
{
$in: {
userId: userIds
}
},
tx
);
return members.map(({ userId, username, groupId, orgId, name, slug, role, roleId }) => ({
user: {
id: userId,
username
},
group: {
id: groupId,
orgId,
name,
slug,
role,
roleId,
createdAt: new Date(),
updatedAt: new Date()
}
}));
} catch (error) {
throw new DatabaseError({ error, name: "Delete pending user group memberships by user ids" });
}
};
return { return {
...userGroupMembershipOrm, ...userGroupMembershipOrm,
filterProjectsByUserMembership, filterProjectsByUserMembership,
findUserGroupMembershipsInProject, findUserGroupMembershipsInProject,
findGroupMembersNotInProject findGroupMembersNotInProject,
deletePendingUserGroupMembershipsByUserIds
}; };
}; };
+209 -27
View File
@@ -4,15 +4,20 @@ import jwt from "jsonwebtoken";
import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups } from "@app/db/schemas"; import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups } from "@app/db/schemas";
import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TGroupDALFactory } from "@app/ee/services/group/group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "@app/ee/services/group/group-fns";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; import { TScimDALFactory } from "@app/ee/services/scim/scim-dal";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TOrgPermission } from "@app/lib/types"; import { TOrgPermission } from "@app/lib/types";
import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal";
import { deleteOrgMembership } from "@app/services/org/org-fns"; import { deleteOrgMembership } from "@app/services/org/org-fns";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
@@ -42,14 +47,21 @@ import {
type TScimServiceFactoryDep = { type TScimServiceFactoryDep = {
scimDAL: Pick<TScimDALFactory, "create" | "find" | "findById" | "deleteById">; scimDAL: Pick<TScimDALFactory, "create" | "find" | "findById" | "deleteById">;
userDAL: Pick<TUserDALFactory, "findOne" | "create" | "transaction">; userDAL: Pick<TUserDALFactory, "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch">;
orgDAL: Pick< orgDAL: Pick<
TOrgDALFactory, TOrgDALFactory,
"createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction" "createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction"
>; >;
projectDAL: Pick<TProjectDALFactory, "find">; projectDAL: Pick<TProjectDALFactory, "find" | "findProjectGhostUser">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">;
groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups">; groupDAL: Pick<
TGroupDALFactory,
"create" | "findOne" | "findAllGroupMembers" | "update" | "delete" | "findGroups" | "transaction"
>;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
userGroupMembershipDAL: TUserGroupMembershipDALFactory; // TODO: Pick
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany" | "delete">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
smtpService: TSmtpService; smtpService: TSmtpService;
@@ -65,6 +77,10 @@ export const scimServiceFactory = ({
projectDAL, projectDAL,
projectMembershipDAL, projectMembershipDAL,
groupDAL, groupDAL,
groupProjectDAL,
userGroupMembershipDAL,
projectKeyDAL,
projectBotDAL,
permissionService, permissionService,
smtpService smtpService
}: TScimServiceFactoryDep) => { }: TScimServiceFactoryDep) => {
@@ -473,7 +489,19 @@ export const scimServiceFactory = ({
}; };
const listScimGroups = async ({ orgId, offset, limit }: TListScimGroupsDTO) => { const listScimGroups = async ({ orgId, offset, limit }: TListScimGroupsDTO) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to list SCIM groups due to plan restriction. Upgrade plan to list SCIM groups."
});
const org = await orgDAL.findById(orgId); const org = await orgDAL.findById(orgId);
if (!org) {
throw new ScimRequestError({
detail: "Organization Not Found",
status: 404
});
}
if (!org.scimEnabled) if (!org.scimEnabled)
throw new ScimRequestError({ throw new ScimRequestError({
@@ -500,30 +528,76 @@ export const scimServiceFactory = ({
}); });
}; };
const createScimGroup = async ({ displayName, orgId }: TCreateScimGroupDTO) => { const createScimGroup = async ({ displayName, orgId, members }: TCreateScimGroupDTO) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to create a SCIM group due to plan restriction. Upgrade plan to create a SCIM group."
});
const org = await orgDAL.findById(orgId); const org = await orgDAL.findById(orgId);
if (!org) {
throw new ScimRequestError({
detail: "Organization Not Found",
status: 404
});
}
if (!org.scimEnabled) if (!org.scimEnabled)
throw new ScimRequestError({ throw new ScimRequestError({
detail: "SCIM is disabled for the organization", detail: "SCIM is disabled for the organization",
status: 403 status: 403
}); });
const group = await groupDAL.create({ const newGroup = await groupDAL.transaction(async (tx) => {
name: displayName, const group = await groupDAL.create(
slug: slugify(`${displayName}-${alphaNumericNanoId(4)}`), {
orgId, name: displayName,
role: OrgMembershipRole.NoAccess slug: slugify(`${displayName}-${alphaNumericNanoId(4)}`),
orgId,
role: OrgMembershipRole.NoAccess
},
tx
);
if (members && members.length) {
const newMembers = await addUsersToGroupByUserIds({
group,
userIds: members.map((member) => member.value),
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
});
return { group, newMembers };
}
return { group, newMembers: [] };
}); });
return buildScimGroup({ return buildScimGroup({
groupId: group.id, groupId: newGroup.group.id,
name: group.name, name: newGroup.group.name,
members: [] members: newGroup.newMembers.map((member) => ({
value: member.id,
display: `${member.firstName} ${member.lastName}`
}))
}); });
}; };
const getScimGroup = async ({ groupId, orgId }: TGetScimGroupDTO) => { const getScimGroup = async ({ groupId, orgId }: TGetScimGroupDTO) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to get SCIM group due to plan restriction. Upgrade plan to get SCIM group."
});
const group = await groupDAL.findOne({ const group = await groupDAL.findOne({
id: groupId, id: groupId,
orgId orgId
@@ -553,35 +627,123 @@ export const scimServiceFactory = ({
}); });
}; };
const updateScimGroupNamePut = async ({ groupId, orgId, displayName }: TUpdateScimGroupNamePutDTO) => { const updateScimGroupNamePut = async ({ groupId, orgId, displayName, members }: TUpdateScimGroupNamePutDTO) => {
const [group] = await groupDAL.update( const plan = await licenseService.getPlan(orgId);
{ if (!plan.groups)
id: groupId, throw new BadRequestError({
orgId message: "Failed to update SCIM group due to plan restriction. Upgrade plan to update SCIM group."
}, });
{
name: displayName
}
);
if (!group) { const org = await orgDAL.findById(orgId);
if (!org) {
throw new ScimRequestError({ throw new ScimRequestError({
detail: "Group Not Found", detail: "Organization Not Found",
status: 404 status: 404
}); });
} }
if (!org.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
const updatedGroup = await groupDAL.transaction(async (tx) => {
const [group] = await groupDAL.update(
{
id: groupId,
orgId
},
{
name: displayName
}
);
if (!group) {
throw new ScimRequestError({
detail: "Group Not Found",
status: 404
});
}
if (members) {
const membersIdsSet = new Set(members.map((member) => member.value));
const directMemberUserIds = (
await userGroupMembershipDAL.find({
groupId: group.id,
isPending: false
})
).map((membership) => membership.userId);
const pendingGroupAdditionsUserIds = (
await userGroupMembershipDAL.find({
groupId: group.id,
isPending: true
})
).map((pendingGroupAddition) => pendingGroupAddition.userId);
const allMembersUserIds = directMemberUserIds.concat(pendingGroupAdditionsUserIds);
const allMembersUserIdsSet = new Set(allMembersUserIds);
const toAddUserIds = members.filter((member) => !allMembersUserIdsSet.has(member.value));
const toRemoveUserIds = allMembersUserIds.filter((userId) => !membersIdsSet.has(userId));
if (toAddUserIds.length) {
await addUsersToGroupByUserIds({
group,
userIds: toAddUserIds.map((member) => member.value),
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
});
}
if (toRemoveUserIds.length) {
await removeUsersFromGroupByUserIds({
group,
userIds: toRemoveUserIds,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
tx
});
}
}
return group;
});
return buildScimGroup({ return buildScimGroup({
groupId: group.id, groupId: updatedGroup.id,
name: group.name, name: updatedGroup.name,
members: [] members
}); });
}; };
// TODO: add support for add/remove op // TODO: add support for add/remove op
const updateScimGroupNamePatch = async ({ groupId, orgId, operations }: TUpdateScimGroupNamePatchDTO) => { const updateScimGroupNamePatch = async ({ groupId, orgId, operations }: TUpdateScimGroupNamePatchDTO) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to update SCIM group due to plan restriction. Upgrade plan to update SCIM group."
});
const org = await orgDAL.findById(orgId); const org = await orgDAL.findById(orgId);
if (!org) {
throw new ScimRequestError({
detail: "Organization Not Found",
status: 404
});
}
if (!org.scimEnabled) if (!org.scimEnabled)
throw new ScimRequestError({ throw new ScimRequestError({
detail: "SCIM is disabled for the organization", detail: "SCIM is disabled for the organization",
@@ -635,6 +797,26 @@ export const scimServiceFactory = ({
}; };
const deleteScimGroup = async ({ groupId, orgId }: TDeleteScimGroupDTO) => { const deleteScimGroup = async ({ groupId, orgId }: TDeleteScimGroupDTO) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.groups)
throw new BadRequestError({
message: "Failed to delete SCIM group due to plan restriction. Upgrade plan to delete SCIM group."
});
const org = await orgDAL.findById(orgId);
if (!org) {
throw new ScimRequestError({
detail: "Organization Not Found",
status: 404
});
}
if (!org.scimEnabled)
throw new ScimRequestError({
detail: "SCIM is disabled for the organization",
status: 403
});
const [group] = await groupDAL.delete({ const [group] = await groupDAL.delete({
id: groupId, id: groupId,
orgId orgId
@@ -81,6 +81,11 @@ export type TListScimGroups = {
export type TCreateScimGroupDTO = { export type TCreateScimGroupDTO = {
displayName: string; displayName: string;
orgId: string; orgId: string;
members?: {
// TODO: account for members with value and display (is this optional?)
value: string;
display: string;
}[];
}; };
export type TGetScimGroupDTO = { export type TGetScimGroupDTO = {
@@ -92,6 +97,10 @@ export type TUpdateScimGroupNamePutDTO = {
groupId: string; groupId: string;
orgId: string; orgId: string;
displayName: string; displayName: string;
members: {
value: string;
display: string;
}[];
}; };
export type TUpdateScimGroupNamePatchDTO = { export type TUpdateScimGroupNamePatchDTO = {
+9
View File
@@ -290,6 +290,10 @@ export const registerRoutes = async (
projectDAL, projectDAL,
projectMembershipDAL, projectMembershipDAL,
groupDAL, groupDAL,
groupProjectDAL,
userGroupMembershipDAL,
projectKeyDAL,
projectBotDAL,
permissionService, permissionService,
smtpService smtpService
}); });
@@ -344,6 +348,11 @@ export const registerRoutes = async (
smtpService, smtpService,
authDAL, authDAL,
userDAL, userDAL,
userGroupMembershipDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
groupProjectDAL,
orgDAL, orgDAL,
orgService, orgService,
licenseService licenseService
@@ -44,7 +44,6 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
if (req.auth.actor !== ActorType.USER) return; if (req.auth.actor !== ActorType.USER) return;
const users = await server.services.org.findAllOrgMembers( const users = await server.services.org.findAllOrgMembers(
req.permission.id, req.permission.id,
req.params.organizationId, req.params.organizationId,
@@ -1,10 +1,16 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { OrgMembershipStatus } from "@app/db/schemas"; import { OrgMembershipStatus } from "@app/db/schemas";
import { convertPendingGroupAdditionsToGroupMemberships } from "@app/ee/services/group/group-fns";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { isDisposableEmail } from "@app/lib/validator"; import { isDisposableEmail } from "@app/lib/validator";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types"; import { TokenType } from "../auth-token/auth-token-types";
@@ -20,6 +26,14 @@ import { AuthMethod, AuthTokenType } from "./auth-type";
type TAuthSignupDep = { type TAuthSignupDep = {
authDAL: TAuthDALFactory; authDAL: TAuthDALFactory;
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"find" | "transaction" | "insertMany" | "deletePendingUserGroupMembershipsByUserIds"
>;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "findLatestProjectKey" | "insertMany">;
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
orgService: Pick<TOrgServiceFactory, "createOrganization">; orgService: Pick<TOrgServiceFactory, "createOrganization">;
orgDAL: TOrgDALFactory; orgDAL: TOrgDALFactory;
tokenService: TAuthTokenServiceFactory; tokenService: TAuthTokenServiceFactory;
@@ -31,6 +45,11 @@ export type TAuthSignupFactory = ReturnType<typeof authSignupServiceFactory>;
export const authSignupServiceFactory = ({ export const authSignupServiceFactory = ({
authDAL, authDAL,
userDAL, userDAL,
userGroupMembershipDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
groupProjectDAL,
tokenService, tokenService,
smtpService, smtpService,
orgService, orgService,
@@ -168,6 +187,16 @@ export const authSignupServiceFactory = ({
const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))];
await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId))); await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId)));
await convertPendingGroupAdditionsToGroupMemberships({
userIds: [user.id],
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL
});
const tokenSession = await tokenService.getUserTokenSession({ const tokenSession = await tokenService.getUserTokenSession({
userAgent, userAgent,
ip, ip,
@@ -270,6 +299,17 @@ export const authSignupServiceFactory = ({
const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))]; const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))];
await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId))); await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId)));
await convertPendingGroupAdditionsToGroupMemberships({
userIds: [user.id],
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx
});
return { info: us, key: userEncKey }; return { info: us, key: userEncKey };
}); });
@@ -32,7 +32,7 @@ type TGroupProjectServiceFactoryDep = {
TGroupProjectMembershipRoleDALFactory, TGroupProjectMembershipRoleDALFactory,
"create" | "transaction" | "insertMany" | "delete" "create" | "transaction" | "insertMany" | "delete"
>; >;
userGroupMembershipDAL: TUserGroupMembershipDALFactory; userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findGroupMembersNotInProject">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "findProjectGhostUser">; projectDAL: Pick<TProjectDALFactory, "findOne" | "findProjectGhostUser">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany" | "transaction">; projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany" | "transaction">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">; projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
@@ -116,68 +116,69 @@ export const groupProjectServiceFactory = ({
}, },
tx tx
); );
// share project key with users in group that have not
// individually been added to the project and that are not part of
// other groups that are in the project
const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id, tx);
if (groupMembers.length) {
const ghostUser = await projectDAL.findProjectGhostUser(project.id, tx);
if (!ghostUser) {
throw new BadRequestError({
message: "Failed to find sudo user"
});
}
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, project.id, tx);
if (!ghostUserLatestKey) {
throw new BadRequestError({
message: "Failed to find sudo user latest key"
});
}
const bot = await projectBotDAL.findOne({ projectId: project.id }, tx);
if (!bot) {
throw new BadRequestError({
message: "Failed to find bot"
});
}
const botPrivateKey = infisicalSymmetricDecrypt({
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey
});
const plaintextProjectKey = decryptAsymmetric({
ciphertext: ghostUserLatestKey.encryptedKey,
nonce: ghostUserLatestKey.nonce,
publicKey: ghostUserLatestKey.sender.publicKey,
privateKey: botPrivateKey
});
const projectKeyData = groupMembers.map(({ user: { publicKey, id } }) => {
const { ciphertext: encryptedKey, nonce } = encryptAsymmetric(plaintextProjectKey, publicKey, botPrivateKey);
return {
encryptedKey,
nonce,
senderId: ghostUser.id,
receiverId: id,
projectId: project.id
};
});
await projectKeyDAL.insertMany(projectKeyData, tx);
}
return groupProjectMembership; return groupProjectMembership;
}); });
// share project key with users in group that have not
// individually been added to the project and that are not part of
// other groups that are in the project
const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id);
if (groupMembers.length) {
const ghostUser = await projectDAL.findProjectGhostUser(project.id);
if (!ghostUser) {
throw new BadRequestError({
message: "Failed to find sudo user"
});
}
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, project.id);
if (!ghostUserLatestKey) {
throw new BadRequestError({
message: "Failed to find sudo user latest key"
});
}
const bot = await projectBotDAL.findOne({ projectId: project.id });
if (!bot) {
throw new BadRequestError({
message: "Failed to find bot"
});
}
const botPrivateKey = infisicalSymmetricDecrypt({
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey
});
const plaintextProjectKey = decryptAsymmetric({
ciphertext: ghostUserLatestKey.encryptedKey,
nonce: ghostUserLatestKey.nonce,
publicKey: ghostUserLatestKey.sender.publicKey,
privateKey: botPrivateKey
});
const projectKeyData = groupMembers.map(({ user: { publicKey, id } }) => {
const { ciphertext: encryptedKey, nonce } = encryptAsymmetric(plaintextProjectKey, publicKey, botPrivateKey);
return {
encryptedKey,
nonce,
senderId: ghostUser.id,
receiverId: id,
projectId: project.id
};
});
await projectKeyDAL.insertMany(projectKeyData);
}
return projectGroup; return projectGroup;
}; };
@@ -287,20 +288,26 @@ export const groupProjectServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Groups); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Groups);
const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id); const deletedProjectGroup = await groupProjectDAL.transaction(async (tx) => {
const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id, tx);
if (groupMembers.length) { if (groupMembers.length) {
await projectKeyDAL.delete({ await projectKeyDAL.delete(
projectId: project.id, {
$in: { projectId: project.id,
receiverId: groupMembers.map(({ user: { id } }) => id) $in: {
} receiverId: groupMembers.map(({ user: { id } }) => id)
}); }
} },
tx
);
}
const [deletedGroup] = await groupProjectDAL.delete({ groupId: group.id, projectId: project.id }); const [projectGroup] = await groupProjectDAL.delete({ groupId: group.id, projectId: project.id }, tx);
return projectGroup;
});
return deletedGroup; return deletedProjectGroup;
}; };
const listGroupsInProject = async ({ const listGroupsInProject = async ({
+20
View File
@@ -89,6 +89,25 @@ export const orgDALFactory = (db: TDbClient) => {
} }
}; };
const countAllOrgMembers = async (orgId: string) => {
try {
interface CountResult {
count: string;
}
const count = await db(TableName.OrgMembership)
.where(`${TableName.OrgMembership}.orgId`, orgId)
.count("*")
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.where({ isGhost: false })
.first();
return parseInt((count as unknown as CountResult).count || "0", 10);
} catch (error) {
throw new DatabaseError({ error, name: "Count all org members" });
}
};
const findOrgMembersByUsername = async (orgId: string, usernames: string[]) => { const findOrgMembersByUsername = async (orgId: string, usernames: string[]) => {
try { try {
const members = await db(TableName.OrgMembership) const members = await db(TableName.OrgMembership)
@@ -269,6 +288,7 @@ export const orgDALFactory = (db: TDbClient) => {
...orgOrm, ...orgOrm,
findOrgByProjectId, findOrgByProjectId,
findAllOrgMembers, findAllOrgMembers,
countAllOrgMembers,
findOrgById, findOrgById,
findAllOrgsByUserId, findAllOrgsByUserId,
ghostUserExists, ghostUserExists,
+1 -1
View File
@@ -248,7 +248,7 @@ export const orgServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
} }
if (authEnforced || scimEnabled) { if (authEnforced) {
const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId); const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId);
if (!samlCfg) if (!samlCfg)
throw new BadRequestError({ throw new BadRequestError({
+2 -2
View File
@@ -81,9 +81,9 @@ export const projectDALFactory = (db: TDbClient) => {
} }
}; };
const findProjectGhostUser = async (projectId: string) => { const findProjectGhostUser = async (projectId: string, tx?: Knex) => {
try { try {
const ghostUser = await db(TableName.ProjectMembership) const ghostUser = await (tx || db)(TableName.ProjectMembership)
.where({ projectId }) .where({ projectId })
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.select(selectAllTableCols(TableName.Users)) .select(selectAllTableCols(TableName.Users))
+14
View File
@@ -34,6 +34,19 @@ export const userDALFactory = (db: TDbClient) => {
} }
}; };
const findUserEncKeyByUserIdsBatch = async ({ userIds }: { userIds: string[] }, tx?: Knex) => {
try {
return await (tx || db)(TableName.Users)
.where({
isGhost: false
})
.whereIn(`${TableName.Users}.id`, userIds)
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`);
} catch (error) {
throw new DatabaseError({ error, name: "Find user enc by user ids batch" });
}
};
const findUserEncKeyByUserId = async (userId: string) => { const findUserEncKeyByUserId = async (userId: string) => {
try { try {
const user = await db(TableName.Users) const user = await db(TableName.Users)
@@ -123,6 +136,7 @@ export const userDALFactory = (db: TDbClient) => {
...userOrm, ...userOrm,
findUserByUsername, findUserByUsername,
findUserEncKeyByUsername, findUserEncKeyByUsername,
findUserEncKeyByUserIdsBatch,
findUserEncKeyByUserId, findUserEncKeyByUserId,
updateUserEncryptionByUserId, updateUserEncryptionByUserId,
findUserByProjectMembershipId, findUserByProjectMembershipId,
@@ -1,32 +1,27 @@
import { useState } from "react"; import { useState } from "react";
import { faMagnifyingGlass,faUsers } from "@fortawesome/free-solid-svg-icons"; import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions"; import { OrgPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
EmptyState, EmptyState,
Input, Input,
Modal, Modal,
ModalContent, ModalContent,
Pagination, Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
TBody, TBody,
Td, Td,
Th, Th,
THead, THead,
Tr} from "@app/components/v2"; Tr
import { } from "@app/components/v2";
OrgPermissionActions, import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
OrgPermissionSubjects import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api";
} from "@app/context";
import {
useAddUserToGroup,
useListGroupUsers,
useRemoveUserFromGroup} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
@@ -34,136 +29,127 @@ type Props = {
handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void; handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void;
}; };
export const OrgGroupMembersModal = ({ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
popUp, const [page, setPage] = useState(1);
handlePopUpToggle const [perPage, setPerPage] = useState(10);
}: Props) => { const [searchMemberFilter, setSearchMemberFilter] = useState("");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const popUpData = popUp?.groupMembers?.data as {
slug: string;
};
const { data, isLoading } = useListGroupUsers({
groupSlug: popUpData?.slug,
offset: (page - 1) * perPage,
limit: perPage,
username: searchMemberFilter
});
const { mutateAsync: assignMutateAsync } = useAddUserToGroup();
const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup();
const handleAssignment = async (username: string, assign: boolean) => {
try {
if (!popUpData?.slug) return;
if (assign) {
await assignMutateAsync({
username,
slug: popUpData.slug
});
} else {
await unassignMutateAsync({
username,
slug: popUpData.slug
});
}
createNotification({ const popUpData = popUp?.groupMembers?.data as {
text: `Successfully ${assign ? "assigned" : "removed "} user ${assign ? "to" : "from"} group`, slug: string;
type: "success" };
});
} catch (err) { const { data, isLoading } = useListGroupUsers({
createNotification({ groupSlug: popUpData?.slug,
text: `Failed to ${assign ? "assigned" : "remove"} user ${assign ? "to" : "from"} group`, offset: (page - 1) * perPage,
type: "error" limit: perPage,
}); username: searchMemberFilter
} });
const { mutateAsync: assignMutateAsync } = useAddUserToGroup();
const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup();
const handleAssignment = async (username: string, assign: boolean) => {
try {
if (!popUpData?.slug) return;
if (assign) {
await assignMutateAsync({
username,
slug: popUpData.slug
});
} else {
await unassignMutateAsync({
username,
slug: popUpData.slug
});
}
createNotification({
text: `Successfully ${assign ? "assigned" : "removed"} user ${
assign ? "to" : "from"
} group`,
type: "success"
});
} catch (err) {
createNotification({
text: `Failed to ${assign ? "assign" : "remove"} user ${assign ? "to" : "from"} group`,
type: "error"
});
} }
};
return (
<Modal return (
isOpen={popUp?.groupMembers?.isOpen} <Modal
onOpenChange={(isOpen) => { isOpen={popUp?.groupMembers?.isOpen}
handlePopUpToggle("groupMembers", isOpen); onOpenChange={(isOpen) => {
}} handlePopUpToggle("groupMembers", isOpen);
> }}
<ModalContent title="Manage Group Members"> >
<Input <ModalContent title="Manage Group Members">
value={searchMemberFilter} <Input
onChange={(e) => setSearchMemberFilter(e.target.value)} value={searchMemberFilter}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} onChange={(e) => setSearchMemberFilter(e.target.value)}
placeholder="Search members..." leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/> placeholder="Search members..."
<TableContainer className="mt-4"> />
<Table> <TableContainer className="mt-4">
<THead> <Table>
<Tr> <THead>
<Th>User</Th> <Tr>
<Th /> <Th>User</Th>
</Tr> <Th />
</THead> </Tr>
<TBody> </THead>
{isLoading && <TableSkeleton columns={2} innerKey="group-users" />} <TBody>
{!isLoading && data?.users?.map(({ {isLoading && <TableSkeleton columns={2} innerKey="group-users" />}
id, {!isLoading &&
firstName, data?.users?.map(({ id, firstName, lastName, username, isPartOfGroup }) => {
lastName, return (
username, <Tr className="items-center" key={`group-user-${id}`}>
isPartOfGroup <Td>
}) => { <p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
return ( <p>{username}</p>
<Tr className="items-center" key={`group-user-${id}`}> </Td>
<Td> <Td className="flex justify-end">
<p>{`${firstName} ${lastName}`}</p> <OrgPermissionCan
<p>{username}</p> I={OrgPermissionActions.Edit}
</Td> a={OrgPermissionSubjects.Groups}
<Td className="flex justify-end"> >
<OrgPermissionCan {(isAllowed) => {
I={OrgPermissionActions.Edit} return (
a={OrgPermissionSubjects.Groups} <Button
> isLoading={isLoading}
{(isAllowed) => { isDisabled={!isAllowed}
return ( colorSchema="primary"
<Button variant="outline_bg"
isLoading={isLoading} type="submit"
isDisabled={!isAllowed} onClick={() => handleAssignment(username, !isPartOfGroup)}
colorSchema="primary" >
variant="outline_bg" {isPartOfGroup ? "Unassign" : "Assign"}
type="submit" </Button>
onClick={() => handleAssignment(username, !isPartOfGroup)} );
> }}
{isPartOfGroup ? "Unassign" : "Assign"} </OrgPermissionCan>
</Button> </Td>
); </Tr>
}} );
</OrgPermissionCan> })}
</Td> </TBody>
</Tr> </Table>
); {!isLoading && data?.totalCount !== undefined && (
})} <Pagination
</TBody> count={data.totalCount}
</Table> page={page}
{!isLoading && data?.totalCount !== undefined && ( perPage={perPage}
<Pagination onChangePage={(newPage) => setPage(newPage)}
count={data.totalCount} onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
page={page} />
perPage={perPage} )}
onChangePage={(newPage) => setPage(newPage)} {!isLoading && !data?.users?.length && (
onChangePerPage={(newPerPage) => setPerPage(newPerPage)} <EmptyState title="No users found" icon={faUsers} />
/> )}
)} </TableContainer>
{!isLoading && !data?.users?.length && ( </ModalContent>
<EmptyState </Modal>
title="No users found" );
icon={faUsers} };
/>
)}
</TableContainer>
</ModalContent>
</Modal>
);
}
@@ -19,7 +19,10 @@ import { UsePopUpState } from "@app/hooks/usePopUp";
const GroupFormSchema = z.object({ const GroupFormSchema = z.object({
name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"), name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"),
slug: z.string().min(5, "Slug cannot be empty").max(36, "Slug must be 36 characters or fewer"), slug: z
.string()
.min(5, "Slug must be at least 5 characters long")
.max(36, "Slug must be 36 characters or fewer"),
role: z.string() role: z.string()
}); });