diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 1301000d58..67c8adfa93 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -170,6 +170,9 @@ import { TIdentityGcpAuths, TIdentityGcpAuthsInsert, TIdentityGcpAuthsUpdate, + TIdentityGroupMembership, + TIdentityGroupMembershipInsert, + TIdentityGroupMembershipUpdate, TIdentityJwtAuths, TIdentityJwtAuthsInsert, TIdentityJwtAuthsUpdate, @@ -857,6 +860,11 @@ declare module "knex/types/tables" { TUserGroupMembershipInsert, TUserGroupMembershipUpdate >; + [TableName.IdentityGroupMembership]: KnexOriginal.CompositeTableType< + TIdentityGroupMembership, + TIdentityGroupMembershipInsert, + TIdentityGroupMembershipUpdate + >; [TableName.GroupProjectMembership]: KnexOriginal.CompositeTableType< TGroupProjectMemberships, TGroupProjectMembershipsInsert, diff --git a/backend/src/db/migrations/20251202162715_add-identity-group-membership.ts b/backend/src/db/migrations/20251202162715_add-identity-group-membership.ts new file mode 100644 index 0000000000..ff67f9a9f1 --- /dev/null +++ b/backend/src/db/migrations/20251202162715_add-identity-group-membership.ts @@ -0,0 +1,28 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.IdentityGroupMembership))) { + await knex.schema.createTable(TableName.IdentityGroupMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("identityId").notNullable(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.uuid("groupId").notNullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.timestamps(true, true, true); + + t.unique(["identityId", "groupId"]); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityGroupMembership); +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityGroupMembership)) { + await knex.schema.dropTable(TableName.IdentityGroupMembership); + await dropOnUpdateTrigger(knex, TableName.IdentityGroupMembership); + } +} diff --git a/backend/src/db/schemas/identity-group-membership.ts b/backend/src/db/schemas/identity-group-membership.ts new file mode 100644 index 0000000000..7dcd65c545 --- /dev/null +++ b/backend/src/db/schemas/identity-group-membership.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IdentityGroupMembershipSchema = z.object({ + id: z.string().uuid(), + identityId: z.string().uuid(), + groupId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TIdentityGroupMembership = z.infer; +export type TIdentityGroupMembershipInsert = Omit, TImmutableDBKeys>; +export type TIdentityGroupMembershipUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 528582c592..3517bd6cd0 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -55,6 +55,7 @@ export * from "./identity-alicloud-auths"; export * from "./identity-aws-auths"; export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; +export * from "./identity-group-membership"; export * from "./identity-jwt-auths"; export * from "./identity-kubernetes-auths"; export * from "./identity-metadata"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index fe38a9c9ba..b18d21163c 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -42,6 +42,7 @@ export enum TableName { GroupProjectMembershipRole = "group_project_membership_roles", ExternalGroupOrgRoleMapping = "external_group_org_role_mappings", UserGroupMembership = "user_group_membership", + IdentityGroupMembership = "identity_group_membership", UserAliases = "user_aliases", UserEncryptionKey = "user_encryption_keys", AuthTokens = "auth_tokens", diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index 4696bef26d..d5d2455a7b 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,18 +1,27 @@ import { z } from "zod"; -import { GroupsSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas"; +import { GroupsSchema, IdentitiesSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas"; import { - EFilterReturnedProjects, - EFilterReturnedUsers, - EGroupProjectsOrderBy + FilterMemberType, + FilterReturnedMachineIdentities, + FilterReturnedProjects, + FilterReturnedUsers, + GroupMembersOrderBy, + GroupProjectsOrderBy } from "@app/ee/services/group/group-types"; import { ApiDocsTags, GROUPS } from "@app/lib/api-docs"; import { OrderByDirection } from "@app/lib/types"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +const GroupIdentityResponseSchema = IdentitiesSchema.pick({ + id: true, + name: true +}); + export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", @@ -190,8 +199,15 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_USERS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), - search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), - filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) + search: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + .describe(GROUPS.LIST_USERS.search), + filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ @@ -202,12 +218,10 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { lastName: true, id: true }) - .merge( - z.object({ - isPartOfGroup: z.boolean(), - joinedGroupAt: z.date().nullable() - }) - ) + .extend({ + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() + }) .array(), totalCount: z.number() }) @@ -227,6 +241,134 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:id/machine-identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Groups], + params: z.object({ + id: z.string().trim().describe(GROUPS.LIST_MACHINE_IDENTITIES.id) + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_MACHINE_IDENTITIES.offset), + limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_MACHINE_IDENTITIES.limit), + search: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + .describe(GROUPS.LIST_MACHINE_IDENTITIES.search), + filter: z + .nativeEnum(FilterReturnedMachineIdentities) + .optional() + .describe(GROUPS.LIST_MACHINE_IDENTITIES.filterMachineIdentities) + }), + response: { + 200: z.object({ + machineIdentities: GroupIdentityResponseSchema.extend({ + isPartOfGroup: z.boolean(), + joinedGroupAt: z.date().nullable() + }).array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { machineIdentities, totalCount } = await server.services.group.listGroupMachineIdentities({ + id: req.params.id, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return { machineIdentities, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id/members", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Groups], + params: z.object({ + id: z.string().trim().describe(GROUPS.LIST_MEMBERS.id) + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_MEMBERS.offset), + limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_MEMBERS.limit), + search: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + .describe(GROUPS.LIST_MEMBERS.search), + orderBy: z + .nativeEnum(GroupMembersOrderBy) + .default(GroupMembersOrderBy.Name) + .optional() + .describe(GROUPS.LIST_MEMBERS.orderBy), + orderDirection: z.nativeEnum(OrderByDirection).optional().describe(GROUPS.LIST_MEMBERS.orderDirection), + memberTypeFilter: z + .union([z.nativeEnum(FilterMemberType), z.array(z.nativeEnum(FilterMemberType))]) + .optional() + .describe(GROUPS.LIST_MEMBERS.memberTypeFilter) + .transform((val) => { + if (!val) return undefined; + return Array.isArray(val) ? val : [val]; + }) + }), + response: { + 200: z.object({ + members: z + .discriminatedUnion("type", [ + z.object({ + id: z.string(), + joinedGroupAt: z.date().nullable(), + type: z.literal("user"), + user: UsersSchema.pick({ id: true, firstName: true, lastName: true, email: true, username: true }) + }), + z.object({ + id: z.string(), + joinedGroupAt: z.date().nullable(), + type: z.literal("machineIdentity"), + machineIdentity: GroupIdentityResponseSchema + }) + ]) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { members, totalCount } = await server.services.group.listGroupMembers({ + id: req.params.id, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return { members, totalCount }; + } + }); + server.route({ method: "GET", url: "/:id/projects", @@ -243,11 +385,18 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { querystring: z.object({ offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_PROJECTS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_PROJECTS.limit), - search: z.string().trim().optional().describe(GROUPS.LIST_PROJECTS.search), - filter: z.nativeEnum(EFilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects), + search: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + .describe(GROUPS.LIST_PROJECTS.search), + filter: z.nativeEnum(FilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects), orderBy: z - .nativeEnum(EGroupProjectsOrderBy) - .default(EGroupProjectsOrderBy.Name) + .nativeEnum(GroupProjectsOrderBy) + .default(GroupProjectsOrderBy.Name) .describe(GROUPS.LIST_PROJECTS.orderBy), orderDirection: z .nativeEnum(OrderByDirection) @@ -263,11 +412,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { description: true, type: true }) - .merge( - z.object({ - joinedGroupAt: z.date().nullable() - }) - ) + .extend({ + joinedGroupAt: z.date().nullable() + }) .array(), totalCount: z.number() }) @@ -325,6 +472,40 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/:id/machine-identities/:machineIdentityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Groups], + params: z.object({ + id: z.string().trim().describe(GROUPS.ADD_MACHINE_IDENTITY.id), + machineIdentityId: z.string().trim().describe(GROUPS.ADD_MACHINE_IDENTITY.machineIdentityId) + }), + response: { + 200: z.object({ + id: z.string() + }) + } + }, + handler: async (req) => { + const machineIdentity = await server.services.group.addMachineIdentityToGroup({ + id: req.params.id, + identityId: req.params.machineIdentityId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return machineIdentity; + } + }); + server.route({ method: "DELETE", url: "/:id/users/:username", @@ -362,4 +543,38 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { return user; } }); + + server.route({ + method: "DELETE", + url: "/:id/machine-identities/:machineIdentityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Groups], + params: z.object({ + id: z.string().trim().describe(GROUPS.DELETE_MACHINE_IDENTITY.id), + machineIdentityId: z.string().trim().describe(GROUPS.DELETE_MACHINE_IDENTITY.machineIdentityId) + }), + response: { + 200: z.object({ + id: z.string() + }) + } + }, + handler: async (req) => { + const machineIdentity = await server.services.group.removeMachineIdentityFromGroup({ + id: req.params.id, + identityId: req.params.machineIdentityId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return machineIdentity; + } + }); }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 6d97e3b9eb..7882eaab7d 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -56,7 +56,7 @@ type TSecretApprovalRequestServiceFactoryDep = { TAccessApprovalRequestReviewerDALFactory, "create" | "find" | "findOne" | "transaction" | "delete" >; - groupDAL: Pick; + groupDAL: Pick; smtpService: Pick; userDAL: Pick< TUserDALFactory, @@ -182,7 +182,7 @@ export const accessApprovalRequestServiceFactory = ({ await Promise.all( approverGroupIds.map((groupApproverId) => groupDAL - .findAllGroupPossibleMembers({ + .findAllGroupPossibleUsers({ orgId: actorOrgId, groupId: groupApproverId }) diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index ced8410b76..eccad82a66 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -6,7 +6,14 @@ import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; import { OrderByDirection } from "@app/lib/types"; -import { EFilterReturnedProjects, EFilterReturnedUsers, EGroupProjectsOrderBy } from "./group-types"; +import { + FilterMemberType, + FilterReturnedMachineIdentities, + FilterReturnedProjects, + FilterReturnedUsers, + GroupMembersOrderBy, + GroupProjectsOrderBy +} from "./group-types"; export type TGroupDALFactory = ReturnType; @@ -70,7 +77,7 @@ export const groupDALFactory = (db: TDbClient) => { }; // special query - const findAllGroupPossibleMembers = async ({ + const findAllGroupPossibleUsers = async ({ orgId, groupId, offset = 0, @@ -85,7 +92,7 @@ export const groupDALFactory = (db: TDbClient) => { limit?: number; username?: string; search?: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedUsers; }) => { try { const query = db @@ -127,11 +134,11 @@ export const groupDALFactory = (db: TDbClient) => { } switch (filter) { - case EFilterReturnedUsers.EXISTING_MEMBERS: - void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is not", null); + case FilterReturnedUsers.EXISTING_MEMBERS: + void query.whereNotNull(`${TableName.UserGroupMembership}.createdAt`); break; - case EFilterReturnedUsers.NON_MEMBERS: - void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is", null); + case FilterReturnedUsers.NON_MEMBERS: + void query.whereNull(`${TableName.UserGroupMembership}.createdAt`); break; default: break; @@ -155,7 +162,7 @@ export const groupDALFactory = (db: TDbClient) => { username: memberUsername, firstName, lastName, - isPartOfGroup: !!memberGroupId, + isPartOfGroup: Boolean(memberGroupId), joinedGroupAt }) ), @@ -167,6 +174,256 @@ export const groupDALFactory = (db: TDbClient) => { } }; + const findAllGroupPossibleMachineIdentities = async ({ + orgId, + groupId, + offset = 0, + limit, + search, + filter + }: { + orgId: string; + groupId: string; + offset?: number; + limit?: number; + search?: string; + filter?: FilterReturnedMachineIdentities; + }) => { + try { + const query = db + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .whereNull(`${TableName.Identity}.projectId`) + .join(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) + .leftJoin(TableName.IdentityGroupMembership, (bd) => { + bd.on(`${TableName.IdentityGroupMembership}.identityId`, "=", `${TableName.Identity}.id`).andOn( + `${TableName.IdentityGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .select( + db.ref("id").withSchema(TableName.Membership), + db.ref("groupId").withSchema(TableName.IdentityGroupMembership), + db.ref("createdAt").withSchema(TableName.IdentityGroupMembership).as("joinedGroupAt"), + db.ref("name").withSchema(TableName.Identity), + db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.raw(`count(*) OVER() as total_count`) + ) + .offset(offset) + .orderBy("name", "asc"); + + if (limit) { + void query.limit(limit); + } + + if (search) { + void query.andWhereRaw(`LOWER("${TableName.Identity}"."name") ilike ?`, `%${search}%`); + } + + switch (filter) { + case FilterReturnedMachineIdentities.ASSIGNED_MACHINE_IDENTITIES: + void query.whereNotNull(`${TableName.IdentityGroupMembership}.createdAt`); + break; + case FilterReturnedMachineIdentities.NON_ASSIGNED_MACHINE_IDENTITIES: + void query.whereNull(`${TableName.IdentityGroupMembership}.createdAt`); + break; + default: + break; + } + + const machineIdentities = await query; + + return { + machineIdentities: machineIdentities.map(({ name, identityId, joinedGroupAt, groupId: identityGroupId }) => ({ + id: identityId, + name, + isPartOfGroup: Boolean(identityGroupId), + joinedGroupAt + })), + // @ts-expect-error col select is raw and not strongly typed + totalCount: Number(machineIdentities?.[0]?.total_count ?? 0) + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find all group identities" }); + } + }; + + const findAllGroupPossibleMembers = async ({ + orgId, + groupId, + offset = 0, + limit, + search, + orderBy = GroupMembersOrderBy.Name, + orderDirection = OrderByDirection.ASC, + memberTypeFilter + }: { + orgId: string; + groupId: string; + offset?: number; + limit?: number; + search?: string; + orderBy?: GroupMembersOrderBy; + orderDirection?: OrderByDirection; + memberTypeFilter?: FilterMemberType[]; + }) => { + try { + const includeUsers = + !memberTypeFilter || memberTypeFilter.length === 0 || memberTypeFilter.includes(FilterMemberType.USERS); + const includeMachineIdentities = + !memberTypeFilter || + memberTypeFilter.length === 0 || + memberTypeFilter.includes(FilterMemberType.MACHINE_IDENTITIES); + + const query = db + .replicaNode()(TableName.Membership) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .leftJoin(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`) + .leftJoin(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) + .leftJoin(TableName.UserGroupMembership, (bd) => { + bd.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn( + `${TableName.UserGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .leftJoin(TableName.IdentityGroupMembership, (bd) => { + bd.on(`${TableName.IdentityGroupMembership}.identityId`, "=", `${TableName.Identity}.id`).andOn( + `${TableName.IdentityGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .where((qb) => { + void qb + .where((innerQb) => { + void innerQb + .whereNotNull(`${TableName.Membership}.actorUserId`) + .whereNotNull(`${TableName.UserGroupMembership}.createdAt`) + .where(`${TableName.Users}.isGhost`, false); + }) + .orWhere((innerQb) => { + void innerQb + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .whereNotNull(`${TableName.IdentityGroupMembership}.createdAt`) + .whereNull(`${TableName.Identity}.projectId`); + }); + }) + .select( + db.raw( + `CASE WHEN "${TableName.Membership}"."actorUserId" IS NOT NULL THEN "${TableName.UserGroupMembership}"."createdAt" ELSE "${TableName.IdentityGroupMembership}"."createdAt" END as "joinedGroupAt"` + ), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.raw(`"${TableName.Users}"."id"::text as "userId"`), + db.raw(`"${TableName.Identity}"."id"::text as "identityId"`), + db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.raw( + `CASE WHEN "${TableName.Membership}"."actorUserId" IS NOT NULL THEN 'user' ELSE 'machineIdentity' END as "member_type"` + ), + db.raw(`count(*) OVER() as total_count`) + ); + + void query.andWhere((qb) => { + if (includeUsers) { + void qb.whereNotNull(`${TableName.Membership}.actorUserId`); + } + + if (includeMachineIdentities) { + void qb[includeUsers ? "orWhere" : "where"]((innerQb) => { + void innerQb.whereNotNull(`${TableName.Membership}.actorIdentityId`); + }); + } + + if (!includeUsers && !includeMachineIdentities) { + void qb.whereRaw("FALSE"); + } + }); + + if (search) { + void query.andWhere((qb) => { + void qb + .whereRaw( + `CONCAT_WS(' ', "${TableName.Users}"."firstName", "${TableName.Users}"."lastName", lower("${TableName.Users}"."username")) ilike ?`, + [`%${search}%`] + ) + .orWhereRaw(`LOWER("${TableName.Identity}"."name") ilike ?`, [`%${search}%`]); + }); + } + + if (orderBy === GroupMembersOrderBy.Name) { + const orderDirectionClause = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"; + + // This order by clause is used to sort the members by name. + // It first checks if the full name (first name and last name) is not empty, then the username, then the email, then the identity name. If all of these are empty, it returns null. + void query.orderByRaw( + `LOWER(COALESCE(NULLIF(TRIM(CONCAT_WS(' ', "${TableName.Users}"."firstName", "${TableName.Users}"."lastName")), ''), "${TableName.Users}"."username", "${TableName.Users}"."email", "${TableName.Identity}"."name")) ${orderDirectionClause}` + ); + } + + if (offset) { + void query.offset(offset); + } + if (limit) { + void query.limit(limit); + } + + const results = (await query) as unknown as { + email: string; + username: string; + firstName: string; + lastName: string; + userId: string; + identityId: string; + identityName: string; + member_type: "user" | "machineIdentity"; + joinedGroupAt: Date; + total_count: string; + }[]; + + const members = results.map( + ({ email, username, firstName, lastName, userId, identityId, identityName, member_type, joinedGroupAt }) => { + if (member_type === "user") { + return { + id: userId, + joinedGroupAt, + type: "user" as const, + user: { + id: userId, + email, + username, + firstName, + lastName + } + }; + } + return { + id: identityId, + joinedGroupAt, + type: "machineIdentity" as const, + machineIdentity: { + id: identityId, + name: identityName + } + }; + } + ); + + return { + members, + totalCount: Number(results?.[0]?.total_count ?? 0) + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find all group possible members" }); + } + }; + const findAllGroupProjects = async ({ orgId, groupId, @@ -182,8 +439,8 @@ export const groupDALFactory = (db: TDbClient) => { offset?: number; limit?: number; search?: string; - filter?: EFilterReturnedProjects; - orderBy?: EGroupProjectsOrderBy; + filter?: FilterReturnedProjects; + orderBy?: GroupProjectsOrderBy; orderDirection?: OrderByDirection; }) => { try { @@ -225,10 +482,10 @@ export const groupDALFactory = (db: TDbClient) => { } switch (filter) { - case EFilterReturnedProjects.ASSIGNED_PROJECTS: + case FilterReturnedProjects.ASSIGNED_PROJECTS: void query.whereNotNull(`${TableName.Membership}.id`); break; - case EFilterReturnedProjects.UNASSIGNED_PROJECTS: + case FilterReturnedProjects.UNASSIGNED_PROJECTS: void query.whereNull(`${TableName.Membership}.id`); break; default: @@ -313,6 +570,8 @@ export const groupDALFactory = (db: TDbClient) => { ...groupOrm, findGroups, findByOrgId, + findAllGroupPossibleUsers, + findAllGroupPossibleMachineIdentities, findAllGroupPossibleMembers, findAllGroupProjects, findGroupsByProjectId, diff --git a/backend/src/ee/services/group/group-fns.ts b/backend/src/ee/services/group/group-fns.ts index c4384cc4e2..e42a86261e 100644 --- a/backend/src/ee/services/group/group-fns.ts +++ b/backend/src/ee/services/group/group-fns.ts @@ -5,9 +5,11 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError, ScimRequestError } from "@app/lib/errors"; import { + TAddIdentitiesToGroup, TAddUsersToGroup, TAddUsersToGroupByUserIds, TConvertPendingGroupAdditionsToGroupMemberships, + TRemoveIdentitiesFromGroup, TRemoveUsersFromGroupByUserIds } from "./group-types"; @@ -285,6 +287,70 @@ export const addUsersToGroupByUserIds = async ({ }); }; +/** + * Add identities with identity ids [identityIds] to group [group]. + * @param {group} group - group to add identity(s) to + * @param {string[]} identityIds - id(s) of organization scoped identity(s) to add to group + * @returns {Promise<{ id: string }[]>} - id(s) of added identity(s) + */ +export const addIdentitiesToGroup = async ({ + group, + identityIds, + identityDAL, + identityGroupMembershipDAL, + membershipDAL +}: TAddIdentitiesToGroup) => { + const identityIdsSet = new Set(identityIds); + const identityIdsArray = Array.from(identityIdsSet); + + // ensure all identities exist and belong to the org via org scoped membership + const foundIdentitiesMemberships = await membershipDAL.find({ + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + $in: { + actorIdentityId: identityIdsArray + } + }); + + const existingIdentityOrgMembershipsIdentityIdsSet = new Set( + foundIdentitiesMemberships.map((u) => u.actorIdentityId as string) + ); + + identityIdsArray.forEach((identityId) => { + if (!existingIdentityOrgMembershipsIdentityIdsSet.has(identityId)) { + throw new ForbiddenRequestError({ + message: `Identity with id ${identityId} is not part of the organization` + }); + } + }); + + // check if identity group membership already exists + const existingIdentityGroupMemberships = await identityGroupMembershipDAL.find({ + groupId: group.id, + $in: { + identityId: identityIdsArray + } + }); + + if (existingIdentityGroupMemberships.length) { + throw new BadRequestError({ + message: `${identityIdsArray.length > 1 ? `Identities are` : `Identity is`} already part of the group ${group.slug}` + }); + } + + return identityDAL.transaction(async (tx) => { + await identityGroupMembershipDAL.insertMany( + foundIdentitiesMemberships.map((membership) => ({ + identityId: membership.actorIdentityId as string, + groupId: group.id + })), + tx + ); + + return identityIdsArray.map((identityId) => ({ id: identityId })); + }); +}; + /** * Remove users with user ids [userIds] from group [group]. * - Users may be part of the group (non-pending + pending); @@ -421,6 +487,75 @@ export const removeUsersFromGroupByUserIds = async ({ }); }; +/** + * Remove identities with identity ids [identityIds] from group [group]. + * @param {group} group - group to remove identity(s) from + * @param {string[]} identityIds - id(s) of identity(s) to remove from group + * @returns {Promise<{ id: string }[]>} - id(s) of removed identity(s) + */ +export const removeIdentitiesFromGroup = async ({ + group, + identityIds, + identityDAL, + membershipDAL, + identityGroupMembershipDAL +}: TRemoveIdentitiesFromGroup) => { + const identityIdsSet = new Set(identityIds); + const identityIdsArray = Array.from(identityIdsSet); + + // ensure all identities exist and belong to the org via org scoped membership + const foundIdentitiesMemberships = await membershipDAL.find({ + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + $in: { + actorIdentityId: identityIdsArray + } + }); + + const foundIdentitiesMembershipsIdentityIdsSet = new Set( + foundIdentitiesMemberships.map((u) => u.actorIdentityId as string) + ); + + if (foundIdentitiesMembershipsIdentityIdsSet.size !== identityIdsArray.length) { + throw new NotFoundError({ + message: `Machine identities not found` + }); + } + + // check if identity group membership already exists + const existingIdentityGroupMemberships = await identityGroupMembershipDAL.find({ + groupId: group.id, + $in: { + identityId: identityIdsArray + } + }); + + const existingIdentityGroupMembershipsIdentityIdsSet = new Set( + existingIdentityGroupMemberships.map((u) => u.identityId) + ); + + identityIdsArray.forEach((identityId) => { + if (!existingIdentityGroupMembershipsIdentityIdsSet.has(identityId)) { + throw new ForbiddenRequestError({ + message: `Machine identities are not part of the group ${group.slug}` + }); + } + }); + return identityDAL.transaction(async (tx) => { + await identityGroupMembershipDAL.delete( + { + groupId: group.id, + $in: { + identityId: identityIdsArray + } + }, + tx + ); + + return identityIdsArray.map((identityId) => ({ id: identityId })); + }); +}; + /** * 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 diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 1a6a046a6b..30d047a776 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -5,6 +5,8 @@ import { AccessScope, OrganizationActionScope, OrgMembershipRole, TRoles } from import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; +import { TMembershipDALFactory } from "@app/services/membership/membership-dal"; import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; @@ -18,33 +20,48 @@ import { OrgPermissionGroupActions, OrgPermissionSubjects } from "../permission/ import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TGroupDALFactory } from "./group-dal"; -import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns"; import { + addIdentitiesToGroup, + addUsersToGroupByUserIds, + removeIdentitiesFromGroup, + removeUsersFromGroupByUserIds +} from "./group-fns"; +import { + TAddMachineIdentityToGroupDTO, TAddUserToGroupDTO, TCreateGroupDTO, TDeleteGroupDTO, TGetGroupByIdDTO, + TListGroupMachineIdentitiesDTO, + TListGroupMembersDTO, TListGroupProjectsDTO, TListGroupUsersDTO, + TRemoveMachineIdentityFromGroupDTO, TRemoveUserFromGroupDTO, TUpdateGroupDTO } from "./group-types"; +import { TIdentityGroupMembershipDALFactory } from "./identity-group-membership-dal"; import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; type TGroupServiceFactoryDep = { userDAL: Pick; + identityDAL: Pick; + identityGroupMembershipDAL: Pick; groupDAL: Pick< TGroupDALFactory, | "create" | "findOne" | "update" | "delete" + | "findAllGroupPossibleUsers" + | "findAllGroupPossibleMachineIdentities" | "findAllGroupPossibleMembers" | "findById" | "transaction" | "findAllGroupProjects" >; membershipGroupDAL: Pick; + membershipDAL: Pick; membershipRoleDAL: Pick; orgDAL: Pick; userGroupMembershipDAL: Pick< @@ -65,6 +82,9 @@ type TGroupServiceFactoryDep = { export type TGroupServiceFactory = ReturnType; export const groupServiceFactory = ({ + identityDAL, + membershipDAL, + identityGroupMembershipDAL, userDAL, groupDAL, orgDAL, @@ -362,7 +382,7 @@ export const groupServiceFactory = ({ message: `Failed to find group with ID ${id}` }); - const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({ + const { members, totalCount } = await groupDAL.findAllGroupPossibleUsers({ orgId: group.orgId, groupId: group.id, offset, @@ -375,6 +395,100 @@ export const groupServiceFactory = ({ return { users: members, totalCount }; }; + const listGroupMachineIdentities = async ({ + id, + offset, + limit, + actor, + actorId, + actorAuthMethod, + actorOrgId, + search, + filter + }: TListGroupMachineIdentitiesDTO) => { + if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); + + const group = await groupDAL.findOne({ + orgId: actorOrgId, + id + }); + + if (!group) + throw new NotFoundError({ + message: `Failed to find group with ID ${id}` + }); + + const { machineIdentities, totalCount } = await groupDAL.findAllGroupPossibleMachineIdentities({ + orgId: group.orgId, + groupId: group.id, + offset, + limit, + search, + filter + }); + + return { machineIdentities, totalCount }; + }; + + const listGroupMembers = async ({ + id, + offset, + limit, + search, + orderBy, + orderDirection, + memberTypeFilter, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListGroupMembersDTO) => { + if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); + + const group = await groupDAL.findOne({ + orgId: actorOrgId, + id + }); + + if (!group) + throw new NotFoundError({ + message: `Failed to find group with ID ${id}` + }); + + const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({ + orgId: group.orgId, + groupId: group.id, + offset, + limit, + search, + orderBy, + orderDirection, + memberTypeFilter + }); + + return { members, totalCount }; + }; + const listGroupProjects = async ({ id, offset, @@ -504,6 +618,81 @@ export const groupServiceFactory = ({ return users[0]; }; + const addMachineIdentityToGroup = async ({ + id, + identityId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TAddMachineIdentityToGroupDTO) => { + if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); + + // check if group with slug exists + const group = await groupDAL.findOne({ + orgId: actorOrgId, + id + }); + + if (!group) + throw new NotFoundError({ + message: `Failed to find group with ID ${id}` + }); + + const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); + + // check if user has broader or equal to privileges than group + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.AddIdentities, + OrgPermissionSubjects.Groups, + permission, + rolePermissionDetails.permission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to add identity to more privileged group", + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.AddIdentities, + OrgPermissionSubjects.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const identityMembership = await membershipDAL.findOne({ + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + actorIdentityId: identityId + }); + + if (!identityMembership) { + throw new NotFoundError({ message: `Identity with id ${identityId} is not part of the organization` }); + } + + const identities = await addIdentitiesToGroup({ + group, + identityIds: [identityId], + identityDAL, + membershipDAL, + identityGroupMembershipDAL + }); + + return identities[0]; + }; + const removeUserFromGroup = async ({ id, username, @@ -587,14 +776,91 @@ export const groupServiceFactory = ({ return users[0]; }; + const removeMachineIdentityFromGroup = async ({ + id, + identityId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TRemoveMachineIdentityFromGroupDTO) => { + if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); + + const group = await groupDAL.findOne({ + orgId: actorOrgId, + id + }); + + if (!group) + throw new NotFoundError({ + message: `Failed to find group with ID ${id}` + }); + + const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); + + // check if user has broader or equal to privileges than group + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.RemoveIdentities, + OrgPermissionSubjects.Groups, + permission, + rolePermissionDetails.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to remove identity from more privileged group", + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.RemoveIdentities, + OrgPermissionSubjects.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + + const identityMembership = await membershipDAL.findOne({ + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + actorIdentityId: identityId + }); + + if (!identityMembership) { + throw new NotFoundError({ message: `Identity with id ${identityId} is not part of the organization` }); + } + + const identities = await removeIdentitiesFromGroup({ + group, + identityIds: [identityId], + identityDAL, + membershipDAL, + identityGroupMembershipDAL + }); + + return identities[0]; + }; + return { createGroup, updateGroup, deleteGroup, listGroupUsers, + listGroupMachineIdentities, + listGroupMembers, listGroupProjects, addUserToGroup, + addMachineIdentityToGroup, removeUserFromGroup, + removeMachineIdentityFromGroup, getGroupById }; }; diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index 335b6d72bb..044dc4bca1 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -3,6 +3,8 @@ import { Knex } from "knex"; import { TGroups } from "@app/db/schemas"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { OrderByDirection, TGenericPermission } from "@app/lib/types"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; +import { TMembershipDALFactory } from "@app/services/membership/membership-dal"; import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -10,6 +12,8 @@ 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 { TIdentityGroupMembershipDALFactory } from "./identity-group-membership-dal"; + export type TCreateGroupDTO = { name: string; slug?: string; @@ -39,7 +43,25 @@ export type TListGroupUsersDTO = { limit: number; username?: string; search?: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedUsers; +} & TGenericPermission; + +export type TListGroupMachineIdentitiesDTO = { + id: string; + offset: number; + limit: number; + search?: string; + filter?: FilterReturnedMachineIdentities; +} & TGenericPermission; + +export type TListGroupMembersDTO = { + id: string; + offset: number; + limit: number; + search?: string; + orderBy?: GroupMembersOrderBy; + orderDirection?: OrderByDirection; + memberTypeFilter?: FilterMemberType[]; } & TGenericPermission; export type TListGroupProjectsDTO = { @@ -47,8 +69,8 @@ export type TListGroupProjectsDTO = { offset: number; limit: number; search?: string; - filter?: EFilterReturnedProjects; - orderBy?: EGroupProjectsOrderBy; + filter?: FilterReturnedProjects; + orderBy?: GroupProjectsOrderBy; orderDirection?: OrderByDirection; } & TGenericPermission; @@ -61,11 +83,21 @@ export type TAddUserToGroupDTO = { username: string; } & TGenericPermission; +export type TAddMachineIdentityToGroupDTO = { + id: string; + identityId: string; +} & TGenericPermission; + export type TRemoveUserFromGroupDTO = { id: string; username: string; } & TGenericPermission; +export type TRemoveMachineIdentityFromGroupDTO = { + id: string; + identityId: string; +} & TGenericPermission; + // group fns types export type TAddUsersToGroup = { @@ -93,6 +125,14 @@ export type TAddUsersToGroupByUserIds = { tx?: Knex; }; +export type TAddIdentitiesToGroup = { + group: TGroups; + identityIds: string[]; + identityDAL: Pick; + identityGroupMembershipDAL: Pick; + membershipDAL: Pick; +}; + export type TRemoveUsersFromGroupByUserIds = { group: TGroups; userIds: string[]; @@ -103,6 +143,14 @@ export type TRemoveUsersFromGroupByUserIds = { tx?: Knex; }; +export type TRemoveIdentitiesFromGroup = { + group: TGroups; + identityIds: string[]; + identityDAL: Pick; + membershipDAL: Pick; + identityGroupMembershipDAL: Pick; +}; + export type TConvertPendingGroupAdditionsToGroupMemberships = { userIds: string[]; userDAL: Pick; @@ -117,16 +165,30 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = { tx?: Knex; }; -export enum EFilterReturnedUsers { +export enum FilterReturnedUsers { EXISTING_MEMBERS = "existingMembers", NON_MEMBERS = "nonMembers" } -export enum EFilterReturnedProjects { +export enum FilterReturnedMachineIdentities { + ASSIGNED_MACHINE_IDENTITIES = "assignedMachineIdentities", + NON_ASSIGNED_MACHINE_IDENTITIES = "nonAssignedMachineIdentities" +} + +export enum FilterReturnedProjects { ASSIGNED_PROJECTS = "assignedProjects", UNASSIGNED_PROJECTS = "unassignedProjects" } -export enum EGroupProjectsOrderBy { +export enum GroupProjectsOrderBy { Name = "name" } + +export enum GroupMembersOrderBy { + Name = "name" +} + +export enum FilterMemberType { + USERS = "users", + MACHINE_IDENTITIES = "machineIdentities" +} diff --git a/backend/src/ee/services/group/identity-group-membership-dal.ts b/backend/src/ee/services/group/identity-group-membership-dal.ts new file mode 100644 index 0000000000..7b6d67244d --- /dev/null +++ b/backend/src/ee/services/group/identity-group-membership-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TIdentityGroupMembershipDALFactory = ReturnType; + +export const identityGroupMembershipDALFactory = (db: TDbClient) => { + const identityGroupMembershipOrm = ormify(db, TableName.IdentityGroupMembership); + + return { + ...identityGroupMembershipOrm + }; +}; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 743dcd63fb..0c00c8b408 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -88,8 +88,10 @@ export enum OrgPermissionGroupActions { Edit = "edit", Delete = "delete", GrantPrivileges = "grant-privileges", + AddIdentities = "add-identities", AddMembers = "add-members", - RemoveMembers = "remove-members" + RemoveMembers = "remove-members", + RemoveIdentities = "remove-identities" } export enum OrgPermissionBillingActions { @@ -381,8 +383,10 @@ const buildAdminPermission = () => { can(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.Delete, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.AddIdentities, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups); can(OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups); + can(OrgPermissionGroupActions.RemoveIdentities, OrgPermissionSubjects.Groups); can(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing); can(OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index efe17edad4..c58bec1561 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -178,6 +178,16 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { .where(`${TableName.UserGroupMembership}.userId`, actorId) .select(db.ref("id").withSchema(TableName.Groups)); + const identityGroupSubquery = (tx || db)(TableName.Groups) + .leftJoin( + TableName.IdentityGroupMembership, + `${TableName.IdentityGroupMembership}.groupId`, + `${TableName.Groups}.id` + ) + .where(`${TableName.Groups}.orgId`, scopeData.orgId) + .where(`${TableName.IdentityGroupMembership}.identityId`, actorId) + .select(db.ref("id").withSchema(TableName.Groups)); + const docs = await (tx || db) .replicaNode()(TableName.Membership) .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) @@ -214,7 +224,9 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { .where(`${TableName.Membership}.actorUserId`, actorId) .orWhereIn(`${TableName.Membership}.actorGroupId`, userGroupSubquery); } else if (actorType === ActorType.IDENTITY) { - void qb.where(`${TableName.Membership}.actorIdentityId`, actorId); + void qb + .where(`${TableName.Membership}.actorIdentityId`, actorId) + .orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery); } }) .where((qb) => { @@ -653,6 +665,15 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { orgId: string ) => { try { + const identityGroupSubquery = db(TableName.Groups) + .leftJoin( + TableName.IdentityGroupMembership, + `${TableName.IdentityGroupMembership}.groupId`, + `${TableName.Groups}.id` + ) + .where(`${TableName.Groups}.orgId`, orgId) + .select(db.ref("id").withSchema(TableName.Groups)); + const docs = await db .replicaNode()(TableName.Membership) .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) @@ -668,7 +689,11 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { void queryBuilder.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`); }) .where(`${TableName.Membership}.scopeOrgId`, orgId) - .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .where((qb) => { + void qb + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery); + }) .where(`${TableName.Membership}.scope`, AccessScope.Project) .where(`${TableName.Membership}.scopeProjectId`, projectId) .select(selectAllTableCols(TableName.MembershipRole)) diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 465ed3ee58..9433c46277 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -72,7 +72,7 @@ type TScimServiceFactoryDep = { TGroupDALFactory, | "create" | "findOne" - | "findAllGroupPossibleMembers" + | "findAllGroupPossibleUsers" | "delete" | "findGroups" | "transaction" @@ -952,7 +952,7 @@ export const scimServiceFactory = ({ } const users = await groupDAL - .findAllGroupPossibleMembers({ + .findAllGroupPossibleUsers({ orgId: group.orgId, groupId: group.id }) diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 81b0c0de2a..9e647369ba 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -106,6 +106,25 @@ export const GROUPS = { filterUsers: "Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization." }, + LIST_MACHINE_IDENTITIES: { + id: "The ID of the group to list identities for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identities to return.", + search: "The text string that machine identity name will be filtered by.", + filterMachineIdentities: + "Whether to filter the list of returned identities. 'assignedMachineIdentities' will only return identities assigned to the group, 'nonAssignedMachineIdentities' will only return identities not assigned to the group, undefined will return all identities in the organization." + }, + LIST_MEMBERS: { + id: "The ID of the group to list members for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th member.", + limit: "The number of members to return.", + search: + "The text string that member email(in case of users) or name(in case of machine identities) will be filtered by.", + orderBy: "The column to order members by.", + orderDirection: "The direction to order members in.", + memberTypeFilter: + "Filter members by type. Can be a single value ('users' or 'machineIdentities') or an array of values. If not specified, both users and machine identities will be returned." + }, LIST_PROJECTS: { id: "The ID of the group to list projects for.", offset: "The offset to start from. If you enter 10, it will start from the 10th project.", @@ -120,12 +139,20 @@ export const GROUPS = { id: "The ID of the group to add the user to.", username: "The username of the user to add to the group." }, + ADD_MACHINE_IDENTITY: { + id: "The ID of the group to add the machine identity to.", + machineIdentityId: "The ID of the machine identity to add to the group." + }, GET_BY_ID: { id: "The ID of the group to fetch." }, DELETE_USER: { id: "The ID of the group to remove the user from.", username: "The username of the user to remove from the group." + }, + DELETE_MACHINE_IDENTITY: { + id: "The ID of the group to remove the machine identity from.", + machineIdentityId: "The ID of the machine identity to remove from the group." } } as const; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index cd4f75e7cf..640c4e9a49 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -46,6 +46,7 @@ import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service"; import { groupDALFactory } from "@app/ee/services/group/group-dal"; import { groupServiceFactory } from "@app/ee/services/group/group-service"; +import { identityGroupMembershipDALFactory } from "@app/ee/services/group/identity-group-membership-dal"; import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; @@ -470,6 +471,7 @@ export const registerRoutes = async ( const identityMetadataDAL = identityMetadataDALFactory(db); const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db); + const identityGroupMembershipDAL = identityGroupMembershipDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db); const identityAuthTemplateDAL = identityAuthTemplateDALFactory(db); @@ -754,6 +756,9 @@ export const registerRoutes = async ( membershipGroupDAL }); const groupService = groupServiceFactory({ + identityDAL, + membershipDAL, + identityGroupMembershipDAL, userDAL, groupDAL, orgDAL, diff --git a/backend/src/server/routes/v1/group-project-router.ts b/backend/src/server/routes/v1/group-project-router.ts index 93caf50351..146891c80e 100644 --- a/backend/src/server/routes/v1/group-project-router.ts +++ b/backend/src/server/routes/v1/group-project-router.ts @@ -9,7 +9,7 @@ import { TemporaryPermissionMode, UsersSchema } from "@app/db/schemas"; -import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; +import { FilterReturnedUsers } from "@app/ee/services/group/group-types"; import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { isUuidV4 } from "@app/lib/validator"; @@ -355,9 +355,10 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => rateLimit: readLimit }, schema: { - hide: false, + hide: true, + deprecated: true, tags: [ApiDocsTags.ProjectGroups], - description: "Return project group users", + description: "Return project group users (Deprecated: Use /api/v1/groups/{id}/users instead)", params: z.object({ projectId: z.string().trim().describe(GROUPS.LIST_USERS.projectId), groupId: z.string().trim().describe(GROUPS.LIST_USERS.id) @@ -367,7 +368,7 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), - filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) + filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v2/deprecated-group-project-router.ts b/backend/src/server/routes/v2/deprecated-group-project-router.ts index 5be7df8387..acfd72a59f 100644 --- a/backend/src/server/routes/v2/deprecated-group-project-router.ts +++ b/backend/src/server/routes/v2/deprecated-group-project-router.ts @@ -9,7 +9,7 @@ import { TemporaryPermissionMode, UsersSchema } from "@app/db/schemas"; -import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; +import { FilterReturnedUsers } from "@app/ee/services/group/group-types"; import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; import { isUuidV4 } from "@app/lib/validator"; @@ -367,7 +367,7 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), - filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) + filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers) }), response: { 200: z.object({ diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 4a4a614967..77497a26cd 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -10,7 +10,7 @@ import { TGroupDALFactory } from "../../ee/services/group/group-dal"; import { TProjectDALFactory } from "../project/project-dal"; type TGroupProjectServiceFactoryDep = { - groupDAL: Pick; + groupDAL: Pick; projectDAL: Pick; permissionService: Pick; }; @@ -51,7 +51,7 @@ export const groupProjectServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); - const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({ + const { members, totalCount } = await groupDAL.findAllGroupPossibleUsers({ orgId: project.orgId, groupId: id, offset, diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 11d4239db0..a25329aee4 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -25,12 +25,27 @@ export const projectDALFactory = (db: TDbClient) => { const findIdentityProjects = async (identityId: string, orgId: string, projectType?: ProjectType) => { try { + const identityGroupSubquery = db + .replicaNode()(TableName.Groups) + .leftJoin( + TableName.IdentityGroupMembership, + `${TableName.IdentityGroupMembership}.groupId`, + `${TableName.Groups}.id` + ) + .where(`${TableName.Groups}.orgId`, orgId) + .where(`${TableName.IdentityGroupMembership}.identityId`, identityId) + .select(db.ref("id").withSchema(TableName.Groups)); + const workspaces = await db .replicaNode()(TableName.Membership) .where(`${TableName.Membership}.scope`, AccessScope.Project) - .where(`${TableName.Membership}.actorIdentityId`, identityId) .join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`) .where(`${TableName.Project}.orgId`, orgId) + .andWhere((qb) => { + void qb + .where(`${TableName.Membership}.actorIdentityId`, identityId) + .orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery); + }) .andWhere((qb) => { if (projectType) { void qb.where(`${TableName.Project}.type`, projectType); @@ -347,11 +362,25 @@ export const projectDALFactory = (db: TDbClient) => { .where(`${TableName.Groups}.orgId`, dto.orgId) .where(`${TableName.UserGroupMembership}.userId`, dto.actorId) .select(db.ref("id").withSchema(TableName.Groups)); + + const identityGroupMembershipSubquery = db + .replicaNode()(TableName.Groups) + .leftJoin( + TableName.IdentityGroupMembership, + `${TableName.IdentityGroupMembership}.groupId`, + `${TableName.Groups}.id` + ) + .where(`${TableName.Groups}.orgId`, dto.orgId) + .where(`${TableName.IdentityGroupMembership}.identityId`, dto.actorId) + .select(db.ref("id").withSchema(TableName.Groups)); + const membershipSubQuery = db(TableName.Membership) .where(`${TableName.Membership}.scope`, AccessScope.Project) .where((qb) => { if (dto.actor === ActorType.IDENTITY) { - void qb.where(`${TableName.Membership}.actorIdentityId`, dto.actorId); + void qb + .where(`${TableName.Membership}.actorIdentityId`, dto.actorId) + .orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupMembershipSubquery); } else { void qb .where(`${TableName.Membership}.actorUserId`, dto.actorId) diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index a7f0eb5655..413ae5a912 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -200,6 +200,11 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`) .leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`) .leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.IdentityGroupMembership, + `${TableName.IdentityGroupMembership}.identityId`, + `${TableName.Identity}.id` + ) .leftJoin(TableName.Membership, (qb) => { void qb .on(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project])) @@ -208,7 +213,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { void sqb .on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`) .orOn(`${TableName.Membership}.actorIdentityId`, `${TableName.SecretVersionV2}.identityActorId`) - .orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`); + .orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`) + .orOn(`${TableName.Membership}.actorGroupId`, `${TableName.IdentityGroupMembership}.groupId`); }); }) .leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) diff --git a/docs/api-reference/endpoints/groups/add-group-machine-identity.mdx b/docs/api-reference/endpoints/groups/add-group-machine-identity.mdx new file mode 100644 index 0000000000..a997cddc7b --- /dev/null +++ b/docs/api-reference/endpoints/groups/add-group-machine-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "Add Machine Identity to Group" +openapi: "POST /api/v1/groups/{id}/machine-identities/{machineIdentityId}" +--- diff --git a/docs/api-reference/endpoints/groups/list-group-machine-identities.mdx b/docs/api-reference/endpoints/groups/list-group-machine-identities.mdx new file mode 100644 index 0000000000..ebe0417132 --- /dev/null +++ b/docs/api-reference/endpoints/groups/list-group-machine-identities.mdx @@ -0,0 +1,4 @@ +--- +title: "List Group Machine Identities" +openapi: "GET /api/v1/groups/{id}/machine-identities" +--- diff --git a/docs/api-reference/endpoints/groups/list-group-members.mdx b/docs/api-reference/endpoints/groups/list-group-members.mdx new file mode 100644 index 0000000000..cf9f39a6ea --- /dev/null +++ b/docs/api-reference/endpoints/groups/list-group-members.mdx @@ -0,0 +1,5 @@ +--- +title: "List Group Members" +openapi: "GET /api/v1/groups/{id}/members" +--- + diff --git a/docs/api-reference/endpoints/groups/list-group-projects.mdx b/docs/api-reference/endpoints/groups/list-group-projects.mdx new file mode 100644 index 0000000000..6dc36a6cfd --- /dev/null +++ b/docs/api-reference/endpoints/groups/list-group-projects.mdx @@ -0,0 +1,5 @@ +--- +title: "List Group Projects" +openapi: "GET /api/v1/groups/{id}/projects" +--- + diff --git a/docs/api-reference/endpoints/groups/remove-group-identity.mdx b/docs/api-reference/endpoints/groups/remove-group-identity.mdx new file mode 100644 index 0000000000..b2ec485579 --- /dev/null +++ b/docs/api-reference/endpoints/groups/remove-group-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Machine Identity from Group" +openapi: "DELETE /api/v1/groups/{id}/machine-identities/{machineIdentityId}" +--- diff --git a/docs/docs.json b/docs/docs.json index fb69a5c849..3783ef9bf3 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -886,7 +886,12 @@ "api-reference/endpoints/groups/get-by-id", "api-reference/endpoints/groups/add-group-user", "api-reference/endpoints/groups/remove-group-user", - "api-reference/endpoints/groups/list-group-users" + "api-reference/endpoints/groups/list-group-users", + "api-reference/endpoints/groups/add-group-machine-identity", + "api-reference/endpoints/groups/remove-group-machine-identity", + "api-reference/endpoints/groups/list-group-machine-identities", + "api-reference/endpoints/groups/list-group-projects", + "api-reference/endpoints/groups/list-group-members" ] }, { diff --git a/docs/documentation/platform/groups.mdx b/docs/documentation/platform/groups.mdx index df4f114366..f7a49ef31c 100644 --- a/docs/documentation/platform/groups.mdx +++ b/docs/documentation/platform/groups.mdx @@ -1,29 +1,29 @@ --- -title: "User Groups" -description: "Manage user groups in Infisical." +title: "Groups" +description: "Manage groups containing users and machine identities in Infisical." --- - User Groups is a paid feature. - - If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, - then you should contact team@infisical.com to purchase an enterprise license to use it. + Groups is a paid feature. If you're using Infisical Cloud, then it is + available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license + to use it. ## Concept -A (user) group is a collection of users that you can create in an Infisical organization to more efficiently manage permissions and access control for multiple users together. For example, you can have a group called `Developers` with the `Developer` role containing all the developers in your organization. +A group is a collection of identities (users and/or machine identities) that you can create in an Infisical organization to more efficiently manage permissions and access control for multiple identities together. For example, you can have a group called `Developers` with the `Developer` role containing all the developers in your organization, or a group called `CI/CD Identities` containing all the machine identities used in your CI/CD pipelines. -User groups have the following properties: +Groups have the following properties: -- If a group is added to a project under specific role(s), all users in the group will be provisioned access to the project with the role(s). Conversely, if a group is removed from a project, all users in the group will lose access to the project. -- If a user is added to a group, they will inherit the access control properties of the group including access to project(s) under the role(s) assigned to the group. Conversely, if a user is removed from a group, they will lose access to project(s) that the group has access to. -- If a user was previously added to a project under a role and is later added to a group that has access to the same project under a different role, then the user will now have access to the project under the composite permissions of the two roles. If the group is subsequently removed from the project, the user will not lose access to the project as they were previously added to the project separately. -- A user can be part of multiple groups. If a user is part of multiple groups, they will inherit the composite permissions of all the groups that they are part of. +- If a group is added to a project under specific role(s), all identities in the group will be provisioned access to the project with the role(s). Conversely, if a group is removed from a project, all identities in the group will lose access to the project. +- If an identity is added to a group, they will inherit the access control properties of the group including access to project(s) under the role(s) assigned to the group. Conversely, if an identity is removed from a group, they will lose access to project(s) that the group has access to. +- If an identity was previously added to a project under a role and is later added to a group that has access to the same project under a different role, then the identity will now have access to the project under the composite permissions of the two roles. If the group is subsequently removed from the project, the identity will not lose access to the project as they were previously added to the project separately. +- An identity can be part of multiple groups. If an identity is part of multiple groups, they will inherit the composite permissions of all the groups that they are part of. ## Workflow -In the following steps, we explore how to create and use user groups to provision user access to projects in Infisical. +In the following steps, we explore how to create and use groups to provision access to projects in Infisical. Groups can contain both users and machine identities, and the workflow is the same for both types of identities. @@ -32,36 +32,38 @@ In the following steps, we explore how to create and use user groups to provisio ![groups org](/images/platform/groups/groups-org.png) When creating a group, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. - + ![groups org create](/images/platform/groups/groups-org-create.png) - + Now input a few details for your new group. Here’s some guidance for each field: - Name (required): A friendly name for the group like `Engineering`. - Slug (required): A unique identifier for the group like `engineering`. - Role (required): A role from the Organization Roles tab for the group to assume. The organization role assigned will determine what organization level resources this group can have access to. + - - Next, you'll want to assign users to the group. To do this, press on the users icon on the group and start assigning users to the group. + + Next, you'll want to assign identities (users and/or machine identities) to the group. To do this, click on the group row to open the group details page and click on the **+** button. - ![groups org users](/images/platform/groups/groups-org-users.png) + ![groups org users details](/images/platform/groups/group-details.png) - In this example, we're assigning **Alan Turing** and **Ada Lovelace** to the group **Engineering**. + In this example, we're assigning **Alan Turing** and **Ada Lovelace** (users) to the group **Engineering**. You can similarly add machine identities to the group by selecting them from the **Machine Identities** tab in the modal. ![groups org assign users](/images/platform/groups/groups-org-users-assign.png) To enable the group to access project-level resources such as secrets within a specific project, you should add it to that project. - To do this, head over to the project you want to add the group to and go to Project Settings > Access Control > Groups and press **Add group**. - + To do this, head over to the project you want to add the group to and go to Project Settings > Access Control > Groups and press **Add Group to Project**. + ![groups project](/images/platform/groups/groups-project.png) - + Next, select the group you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this group can have access to. - + ![groups project add](/images/platform/groups/groups-project-create.png) - + That's it! - - The users of the group now have access to the project under the role you assigned to the group. + + All identities of the group now have access to the project under the role you assigned to the group. + - \ No newline at end of file + diff --git a/docs/images/platform/groups/group-details.png b/docs/images/platform/groups/group-details.png new file mode 100644 index 0000000000..3cd94241db Binary files /dev/null and b/docs/images/platform/groups/group-details.png differ diff --git a/docs/images/platform/groups/groups-org-create.png b/docs/images/platform/groups/groups-org-create.png index a8a1e677cb..25af815e1b 100644 Binary files a/docs/images/platform/groups/groups-org-create.png and b/docs/images/platform/groups/groups-org-create.png differ diff --git a/docs/images/platform/groups/groups-org-users-assign.png b/docs/images/platform/groups/groups-org-users-assign.png index b5f629c2ec..b1e6cbf87a 100644 Binary files a/docs/images/platform/groups/groups-org-users-assign.png and b/docs/images/platform/groups/groups-org-users-assign.png differ diff --git a/docs/images/platform/groups/groups-org-users.png b/docs/images/platform/groups/groups-org-users.png deleted file mode 100644 index 383425e776..0000000000 Binary files a/docs/images/platform/groups/groups-org-users.png and /dev/null differ diff --git a/docs/images/platform/groups/groups-org.png b/docs/images/platform/groups/groups-org.png index 13b2edc445..db9ddb2d1b 100644 Binary files a/docs/images/platform/groups/groups-org.png and b/docs/images/platform/groups/groups-org.png differ diff --git a/docs/images/platform/groups/groups-project-create.png b/docs/images/platform/groups/groups-project-create.png index 9232aa042f..87e4fdf6b4 100644 Binary files a/docs/images/platform/groups/groups-project-create.png and b/docs/images/platform/groups/groups-project-create.png differ diff --git a/docs/images/platform/groups/groups-project.png b/docs/images/platform/groups/groups-project.png index 83e384861a..cd5ec63bc6 100644 Binary files a/docs/images/platform/groups/groups-project.png and b/docs/images/platform/groups/groups-project.png differ diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx index eebe2ccc3a..b705ec3ade 100644 --- a/frontend/src/hooks/api/groups/index.tsx +++ b/frontend/src/hooks/api/groups/index.tsx @@ -1,8 +1,15 @@ export { + useAddIdentityToGroup, useAddUserToGroup, useCreateGroup, useDeleteGroup, + useRemoveIdentityFromGroup, useRemoveUserFromGroup, useUpdateGroup } from "./mutations"; -export { useGetGroupById, useListGroupProjects, useListGroupUsers } from "./queries"; +export { + useGetGroupById, + useListGroupMachineIdentities, + useListGroupProjects, + useListGroupUsers +} from "./queries"; diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx index d45971995f..ea5ac22730 100644 --- a/frontend/src/hooks/api/groups/mutations.tsx +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -5,7 +5,7 @@ import { apiRequest } from "@app/config/request"; import { organizationKeys } from "../organization/queries"; import { userKeys } from "../users/query-keys"; import { groupKeys } from "./queries"; -import { TGroup } from "./types"; +import { TGroup, TGroupMachineIdentity } from "./types"; export const useCreateGroup = () => { const queryClient = useQueryClient(); @@ -95,6 +95,7 @@ export const useAddUserToGroup = () => { }, onSuccess: (_, { slug }) => { queryClient.invalidateQueries({ queryKey: groupKeys.forGroupUserMemberships(slug) }); + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) }); } }); }; @@ -119,6 +120,55 @@ export const useRemoveUserFromGroup = () => { onSuccess: (_, { slug, username }) => { queryClient.invalidateQueries({ queryKey: groupKeys.forGroupUserMemberships(slug) }); queryClient.invalidateQueries({ queryKey: userKeys.listUserGroupMemberships(username) }); + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) }); + } + }); +}; + +export const useAddIdentityToGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + groupId, + identityId + }: { + groupId: string; + identityId: string; + slug: string; + }) => { + const { data } = await apiRequest.post>( + `/api/v1/groups/${groupId}/machine-identities/${identityId}` + ); + + return data; + }, + onSuccess: (_, { slug }) => { + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupIdentitiesMemberships(slug) }); + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) }); + } + }); +}; + +export const useRemoveIdentityFromGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + groupId, + identityId + }: { + groupId: string; + identityId: string; + slug: string; + }) => { + const { data } = await apiRequest.delete>( + `/api/v1/groups/${groupId}/machine-identities/${identityId}` + ); + + return data; + }, + onSuccess: (_, { slug }) => { + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupIdentitiesMemberships(slug) }); + queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) }); } }); }; diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index 2e5b9f6841..cd2b9a00cb 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -4,9 +4,14 @@ import { apiRequest } from "@app/config/request"; import { OrderByDirection } from "../generic/types"; import { - EFilterReturnedProjects, - EFilterReturnedUsers, + FilterMemberType, + FilterReturnedMachineIdentities, + FilterReturnedProjects, + FilterReturnedUsers, + GroupMembersOrderBy, TGroup, + TGroupMachineIdentity, + TGroupMember, TGroupProject, TGroupUser } from "./types"; @@ -27,10 +32,12 @@ export const groupKeys = { offset: number; limit: number; search: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedUsers; }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const, - specificProjectGroupUserMemberships: ({ - projectId, + allGroupIdentitiesMemberships: () => ["group-identities-memberships"] as const, + forGroupIdentitiesMemberships: (slug: string) => + [...groupKeys.allGroupIdentitiesMemberships(), slug] as const, + specificGroupIdentitiesMemberships: ({ slug, offset, limit, @@ -38,16 +45,34 @@ export const groupKeys = { filter }: { slug: string; - projectId: string; offset: number; limit: number; search: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedMachineIdentities; + }) => + [...groupKeys.forGroupIdentitiesMemberships(slug), { offset, limit, search, filter }] as const, + allGroupMembers: () => ["group-members"] as const, + forGroupMembers: (slug: string) => [...groupKeys.allGroupMembers(), slug] as const, + specificGroupMembers: ({ + slug, + offset, + limit, + search, + orderBy, + orderDirection, + memberTypeFilter + }: { + slug: string; + offset: number; + limit: number; + search: string; + orderBy?: GroupMembersOrderBy; + orderDirection?: OrderByDirection; + memberTypeFilter?: FilterMemberType[]; }) => [ - ...groupKeys.forGroupUserMemberships(slug), - projectId, - { offset, limit, search, filter } + ...groupKeys.forGroupMembers(slug), + { offset, limit, search, orderBy, orderDirection, memberTypeFilter } ] as const, allGroupProjects: () => ["group-projects"] as const, forGroupProjects: (groupId: string) => [...groupKeys.allGroupProjects(), groupId] as const, @@ -64,7 +89,7 @@ export const groupKeys = { offset: number; limit: number; search: string; - filter?: EFilterReturnedProjects; + filter?: FilterReturnedProjects; orderBy?: string; orderDirection?: OrderByDirection; }) => @@ -99,7 +124,7 @@ export const useListGroupUsers = ({ offset: number; limit: number; search: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedUsers; }) => { return useQuery({ queryKey: groupKeys.specificGroupUserMemberships({ @@ -115,7 +140,7 @@ export const useListGroupUsers = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search, + ...(search && { search }), ...(filter && { filter }) }); @@ -131,9 +156,66 @@ export const useListGroupUsers = ({ }); }; -export const useListProjectGroupUsers = ({ +export const useListGroupMembers = ({ + id, + groupSlug, + offset = 0, + limit = 10, + search, + orderBy, + orderDirection, + memberTypeFilter +}: { + id: string; + groupSlug: string; + offset: number; + limit: number; + search: string; + orderBy?: GroupMembersOrderBy; + orderDirection?: OrderByDirection; + memberTypeFilter?: FilterMemberType[]; +}) => { + return useQuery({ + queryKey: groupKeys.specificGroupMembers({ + slug: groupSlug, + offset, + limit, + search, + orderBy, + orderDirection, + memberTypeFilter + }), + enabled: Boolean(groupSlug), + placeholderData: (previousData) => previousData, + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + ...(search && { search }), + ...(orderBy && { orderBy: orderBy.toString() }), + ...(orderDirection && { orderDirection }) + }); + + if (memberTypeFilter && memberTypeFilter.length > 0) { + memberTypeFilter.forEach((filter) => { + params.append("memberTypeFilter", filter); + }); + } + + const { data } = await apiRequest.get<{ members: TGroupMember[]; totalCount: number }>( + `/api/v1/groups/${id}/members`, + { + params + } + ); + + return data; + } + }); +}; + +export const useListGroupMachineIdentities = ({ id, - projectId, groupSlug, offset = 0, limit = 10, @@ -142,16 +224,14 @@ export const useListProjectGroupUsers = ({ }: { id: string; groupSlug: string; - projectId: string; offset: number; limit: number; search: string; - filter?: EFilterReturnedUsers; + filter?: FilterReturnedMachineIdentities; }) => { return useQuery({ - queryKey: groupKeys.specificProjectGroupUserMemberships({ + queryKey: groupKeys.specificGroupIdentitiesMemberships({ slug: groupSlug, - projectId, offset, limit, search, @@ -163,16 +243,16 @@ export const useListProjectGroupUsers = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search, + ...(search && { search }), ...(filter && { filter }) }); - const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>( - `/api/v1/projects/${projectId}/groups/${id}/users`, - { - params - } - ); + const { data } = await apiRequest.get<{ + machineIdentities: TGroupMachineIdentity[]; + totalCount: number; + }>(`/api/v1/groups/${id}/machine-identities`, { + params + }); return data; } @@ -194,7 +274,7 @@ export const useListGroupProjects = ({ search: string; orderBy?: string; orderDirection?: OrderByDirection; - filter?: EFilterReturnedProjects; + filter?: FilterReturnedProjects; }) => { return useQuery({ queryKey: groupKeys.specificGroupProjects({ @@ -212,7 +292,7 @@ export const useListGroupProjects = ({ const params = new URLSearchParams({ offset: String(offset), limit: String(limit), - search, + ...(search && { search }), ...(filter && { filter }), ...(orderBy && { orderBy }), ...(orderDirection && { orderDirection }) diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts index 1c16a331b6..f5549a3c1d 100644 --- a/frontend/src/hooks/api/groups/types.ts +++ b/frontend/src/hooks/api/groups/types.ts @@ -42,16 +42,59 @@ export type TGroupWithProjectMemberships = { orgId: string; }; +export enum GroupMemberType { + USER = "user", + MACHINE_IDENTITY = "machineIdentity" +} + export type TGroupUser = { id: string; email: string; username: string; firstName: string; lastName: string; - isPartOfGroup: boolean; joinedGroupAt: Date; }; +export type TGroupMachineIdentity = { + id: string; + name: string; + joinedGroupAt: Date; +}; + +export type TGroupMemberUser = { + id: string; + joinedGroupAt: Date; + type: GroupMemberType.USER; + user: { + email: string; + username: string; + firstName: string; + lastName: string; + }; +}; + +export type TGroupMemberMachineIdentity = { + id: string; + joinedGroupAt: Date; + type: GroupMemberType.MACHINE_IDENTITY; + machineIdentity: { + id: string; + name: string; + }; +}; + +export type TGroupMember = TGroupMemberUser | TGroupMemberMachineIdentity; + +export enum GroupMembersOrderBy { + Name = "name" +} + +export enum FilterMemberType { + USERS = "users", + MACHINE_IDENTITIES = "machineIdentities" +} + export type TGroupProject = { id: string; name: string; @@ -61,12 +104,17 @@ export type TGroupProject = { joinedGroupAt: Date; }; -export enum EFilterReturnedUsers { +export enum FilterReturnedUsers { EXISTING_MEMBERS = "existingMembers", NON_MEMBERS = "nonMembers" } -export enum EFilterReturnedProjects { +export enum FilterReturnedMachineIdentities { + ASSIGNED_MACHINE_IDENTITIES = "assignedMachineIdentities", + NON_ASSIGNED_MACHINE_IDENTITIES = "nonAssignedMachineIdentities" +} + +export enum FilterReturnedProjects { ASSIGNED_PROJECTS = "assignedProjects", UNASSIGNED_PROJECTS = "unassignedProjects" } diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModal.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModal.tsx index b9d1b184c3..9dbd193c4e 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModal.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModal.tsx @@ -1,39 +1,35 @@ import { useState } from "react"; -import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons"; +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { HardDriveIcon, UserIcon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; -import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; -import { - Button, - EmptyState, - Input, - Modal, - ModalContent, - Pagination, - Table, - TableContainer, - TableSkeleton, - TBody, - Td, - Th, - THead, - Tr -} from "@app/components/v2"; -import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context"; -import { useDebounce, useResetPageHelper } from "@app/hooks"; -import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api"; -import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; +import { Button, Input, Modal, ModalContent, Tooltip } from "@app/components/v2"; +import { useDebounce } from "@app/hooks"; import { UsePopUpState } from "@app/hooks/usePopUp"; +import { AddGroupIdentitiesTab, AddGroupUsersTab } from "./AddGroupMemberModalTabs"; + +enum AddMemberType { + Users = "users", + MachineIdentities = "machineIdentities" +} + type Props = { popUp: UsePopUpState<["addGroupMembers"]>; handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void; + isOidcManageGroupMembershipsEnabled: boolean; }; -export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { - const [page, setPage] = useState(1); - const [perPage, setPerPage] = useState(10); +export const AddGroupMembersModal = ({ + popUp, + handlePopUpToggle, + isOidcManageGroupMembershipsEnabled +}: Props) => { + const [addMemberType, setAddMemberType] = useState( + isOidcManageGroupMembershipsEnabled ? AddMemberType.MachineIdentities : AddMemberType.Users + ); + const [searchMemberFilter, setSearchMemberFilter] = useState(""); const [debouncedSearch] = useDebounce(searchMemberFilter); @@ -42,47 +38,6 @@ export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { slug: string; }; - const offset = (page - 1) * perPage; - const { data, isPending } = useListGroupUsers({ - id: popUpData?.groupId, - groupSlug: popUpData?.slug, - offset, - limit: perPage, - search: debouncedSearch, - filter: EFilterReturnedUsers.NON_MEMBERS - }); - - const { totalCount = 0 } = data ?? {}; - - useResetPageHelper({ - totalCount, - offset, - setPage - }); - - const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup(); - - const handleAddMember = async (username: string) => { - if (!popUpData?.slug) { - createNotification({ - text: "Some data is missing, please refresh the page and try again", - type: "error" - }); - return; - } - - await addUserToGroupMutateAsync({ - groupId: popUpData.groupId, - username, - slug: popUpData.slug - }); - - createNotification({ - text: "Successfully assigned user to the group", - type: "success" - }); - }; - return ( { }} > - setSearchMemberFilter(e.target.value)} - leftIcon={} - placeholder="Search members..." - /> - - - - - - - - - {isPending && } - {!isPending && - data?.users?.map(({ id, firstName, lastName, username }) => { - return ( - - - - - ); - })} - -
User -
-

{`${firstName ?? "-"} ${lastName ?? ""}`}

-

{username}

-
- - {(isAllowed) => { - return ( - - ); - }} - -
- {!isPending && totalCount > 0 && ( - setPage(newPage)} - onChangePerPage={(newPerPage) => setPerPage(newPerPage)} +
+ +
+ +
+
+ +
+
+ setSearchMemberFilter(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> +
+ {addMemberType === AddMemberType.Users && + popUpData && + !isOidcManageGroupMembershipsEnabled && ( + )} - {!isPending && !data?.users?.length && ( - - )} -
+ {addMemberType === AddMemberType.MachineIdentities && popUpData && ( + + )}
); diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupIdentitiesTab.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupIdentitiesTab.tsx new file mode 100644 index 0000000000..0e09c0bbe9 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupIdentitiesTab.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import { faServer } from "@fortawesome/free-solid-svg-icons"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context"; +import { useResetPageHelper } from "@app/hooks"; +import { useAddIdentityToGroup, useListGroupMachineIdentities } from "@app/hooks/api"; +import { + FilterReturnedMachineIdentities, + TGroupMachineIdentity +} from "@app/hooks/api/groups/types"; + +type Props = { + groupId: string; + groupSlug: string; + search: string; +}; + +export const AddGroupIdentitiesTab = ({ groupId, groupSlug, search }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + + const offset = (page - 1) * perPage; + const { data, isPending } = useListGroupMachineIdentities({ + id: groupId, + groupSlug, + offset, + limit: perPage, + search, + filter: FilterReturnedMachineIdentities.NON_ASSIGNED_MACHINE_IDENTITIES + }); + + const { totalCount = 0 } = data ?? {}; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { mutateAsync: addIdentityToGroupMutateAsync } = useAddIdentityToGroup(); + + const handleAddIdentity = async (identityId: string) => { + if (!groupSlug) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" + }); + return; + } + + await addIdentityToGroupMutateAsync({ + groupId, + identityId, + slug: groupSlug + }); + + createNotification({ + text: "Successfully assigned machine identity to the group", + type: "success" + }); + }; + + return ( + + + + + + + + + {isPending && } + {!isPending && + data?.machineIdentities?.map((identity: TGroupMachineIdentity) => { + return ( + + + + + ); + })} + +
Machine Identity +
+

{identity.name}

+
+ + {(isAllowed) => { + return ( + + ); + }} + +
+ {!isPending && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && !data?.machineIdentities?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupUsersTab.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupUsersTab.tsx new file mode 100644 index 0000000000..a2ef53feba --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/AddGroupUsersTab.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { faUsers } from "@fortawesome/free-solid-svg-icons"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context"; +import { useResetPageHelper } from "@app/hooks"; +import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api"; +import { FilterReturnedUsers } from "@app/hooks/api/groups/types"; + +type Props = { + groupId: string; + groupSlug: string; + search: string; +}; + +export const AddGroupUsersTab = ({ groupId, groupSlug, search }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + + const offset = (page - 1) * perPage; + const { data, isPending } = useListGroupUsers({ + id: groupId, + groupSlug, + offset, + limit: perPage, + search, + filter: FilterReturnedUsers.NON_MEMBERS + }); + + const { totalCount = 0 } = data ?? {}; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup(); + + const handleAddUser = async (username: string) => { + if (!groupSlug) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" + }); + return; + } + + await addUserToGroupMutateAsync({ + groupId, + username, + slug: groupSlug + }); + + createNotification({ + text: "Successfully assigned user to the group", + type: "success" + }); + }; + + return ( + + + + + + + + + {isPending && } + {!isPending && + data?.users?.map(({ id, firstName, lastName, username }) => { + return ( + + + + + ); + })} + +
User +
+

{`${firstName ?? "-"} ${lastName ?? ""}`}

+

{username}

+
+ + {(isAllowed) => { + return ( + + ); + }} + +
+ {!isPending && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && !data?.users?.length && ( + + )} +
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/index.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/index.tsx new file mode 100644 index 0000000000..e40c6da6b7 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupMemberModalTabs/index.tsx @@ -0,0 +1,2 @@ +export { AddGroupIdentitiesTab } from "./AddGroupIdentitiesTab"; +export { AddGroupUsersTab } from "./AddGroupUsersTab"; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx index c7040ec54a..ec97c216cd 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx @@ -27,7 +27,7 @@ import { useAddGroupToWorkspace as useAddProjectToGroup, useListGroupProjects } from "@app/hooks/api"; -import { EFilterReturnedProjects } from "@app/hooks/api/groups/types"; +import { FilterReturnedProjects } from "@app/hooks/api/groups/types"; import { ProjectType } from "@app/hooks/api/projects/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -57,7 +57,7 @@ export const AddGroupProjectModal = ({ popUp, handlePopUpToggle }: Props) => { offset, limit: perPage, search: debouncedSearch, - filter: EFilterReturnedProjects.UNASSIGNED_PROJECTS + filter: FilterReturnedProjects.UNASSIGNED_PROJECTS }); const { totalCount = 0 } = data ?? {}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx index 024bc54d64..da1a627f63 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx @@ -3,9 +3,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context"; -import { useOidcManageGroupMembershipsEnabled, useRemoveUserFromGroup } from "@app/hooks/api"; +import { + useOidcManageGroupMembershipsEnabled, + useRemoveIdentityFromGroup, + useRemoveUserFromGroup +} from "@app/hooks/api"; +import { GroupMemberType } from "@app/hooks/api/groups/types"; import { usePopUp } from "@app/hooks/usePopUp"; import { AddGroupMembersModal } from "../AddGroupMemberModal"; @@ -16,6 +21,10 @@ type Props = { groupSlug: string; }; +type RemoveMemberData = + | { memberType: GroupMemberType.USER; username: string } + | { memberType: GroupMemberType.MACHINE_IDENTITY; identityId: string; name: string }; + export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ "addGroupMembers", @@ -28,52 +37,66 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { useOidcManageGroupMembershipsEnabled(currentOrg.id); const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup(); - const handleRemoveUserFromGroup = async (username: string) => { - await removeUserFromGroupMutateAsync({ - groupId, - username, - slug: groupSlug - }); + const { mutateAsync: removeIdentityFromGroupMutateAsync } = useRemoveIdentityFromGroup(); - createNotification({ - text: `Successfully removed user ${username} from the group`, - type: "success" - }); + const handleRemoveMemberFromGroup = async (memberData: RemoveMemberData) => { + if (memberData.memberType === GroupMemberType.USER) { + await removeUserFromGroupMutateAsync({ + groupId, + username: memberData.username, + slug: groupSlug + }); + + createNotification({ + text: `Successfully removed user ${memberData.username} from the group`, + type: "success" + }); + } else { + await removeIdentityFromGroupMutateAsync({ + groupId, + identityId: memberData.identityId, + slug: groupSlug + }); + + createNotification({ + text: `Successfully removed identity ${memberData.name} from the group`, + type: "success" + }); + } handlePopUpToggle("removeMemberFromGroup", false); }; + const getMemberName = (memberData: RemoveMemberData) => { + if (!memberData) return ""; + if (memberData.memberType === GroupMemberType.USER) { + return memberData.username; + } + return memberData.name; + }; + return (
-

Members

+

Group Members

{(isAllowed) => ( - -
- { - handlePopUpOpen("addGroupMembers", { - groupId, - slug: groupSlug - }); - }} - > - - -
-
+
+ { + handlePopUpOpen("addGroupMembers", { + groupId, + slug: groupSlug + }); + }} + > + + +
)}
@@ -84,21 +107,19 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { handlePopUpOpen={handlePopUpOpen} />
- + handlePopUpToggle("removeMemberFromGroup", isOpen)} deleteKey="confirm" onDeleteApproved={() => { - const userData = popUp?.removeMemberFromGroup?.data as { - username: string; - id: string; - }; - - return handleRemoveUserFromGroup(userData.username); + const memberData = popUp?.removeMemberFromGroup?.data as RemoveMemberData; + return handleRemoveMemberFromGroup(memberData); }} /> diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx index f56f852d4a..0902b01d2f 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -1,16 +1,25 @@ -import { useMemo } from "react"; +import { useState } from "react"; import { faArrowDown, faArrowUp, + faCheckCircle, + faFilter, faFolder, faMagnifyingGlass, faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { HardDriveIcon, UserIcon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, EmptyState, IconButton, Input, @@ -31,12 +40,18 @@ import { setUserTablePreference } from "@app/helpers/userTablePreferences"; import { usePagination, useResetPageHelper } from "@app/hooks"; -import { useListGroupUsers, useOidcManageGroupMembershipsEnabled } from "@app/hooks/api"; +import { useOidcManageGroupMembershipsEnabled } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; -import { EFilterReturnedUsers } from "@app/hooks/api/groups/types"; +import { useListGroupMembers } from "@app/hooks/api/groups/queries"; +import { + FilterMemberType, + GroupMembersOrderBy, + GroupMemberType +} from "@app/hooks/api/groups/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; -import { GroupMembershipRow } from "./GroupMembershipRow"; +import { GroupMembershipIdentityRow } from "./GroupMembershipIdentityRow"; +import { GroupMembershipUserRow } from "./GroupMembershipUserRow"; type Props = { groupId: string; @@ -47,10 +62,6 @@ type Props = { ) => void; }; -enum GroupMembersOrderBy { - Name = "name" -} - export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => { const { search, @@ -61,11 +72,14 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props setPerPage, offset, orderDirection, - toggleOrderDirection + toggleOrderDirection, + orderBy } = usePagination(GroupMembersOrderBy.Name, { initPerPage: getUserTablePreference("groupMembersTable", PreferenceKey.PerPage, 20) }); + const [memberTypeFilter, setMemberTypeFilter] = useState([]); + const handlePerPageChange = (newPerPage: number) => { setPerPage(newPerPage); setUserTablePreference("groupMembersTable", PreferenceKey.PerPage, newPerPage); @@ -76,66 +90,103 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props const { data: isOidcManageGroupMembershipsEnabled = false } = useOidcManageGroupMembershipsEnabled(currentOrg.id); - const { data: groupMemberships, isPending } = useListGroupUsers({ + const { data: groupMemberships, isPending } = useListGroupMembers({ id: groupId, groupSlug, offset, limit: perPage, search, - filter: EFilterReturnedUsers.EXISTING_MEMBERS + orderBy, + orderDirection, + memberTypeFilter: memberTypeFilter.length > 0 ? memberTypeFilter : undefined }); - const filteredGroupMemberships = useMemo(() => { - return groupMemberships && groupMemberships?.users - ? groupMemberships?.users - ?.filter((membership) => { - const userSearchString = `${membership.firstName && membership.firstName} ${ - membership.lastName && membership.lastName - } ${membership.email && membership.email} ${ - membership.username && membership.username - }`; - return userSearchString.toLowerCase().includes(search.trim().toLowerCase()); - }) - .sort((a, b) => { - const [membershipOne, membershipTwo] = - orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; - - const membershipOneComparisonString = membershipOne.firstName - ? membershipOne.firstName - : membershipOne.email; - - const membershipTwoComparisonString = membershipTwo.firstName - ? membershipTwo.firstName - : membershipTwo.email; - - const comparison = membershipOneComparisonString - .toLowerCase() - .localeCompare(membershipTwoComparisonString.toLowerCase()); - - return comparison; - }) - : []; - }, [groupMemberships, orderDirection, search]); + const { members = [], totalCount = 0 } = groupMemberships ?? {}; useResetPageHelper({ - totalCount: filteredGroupMemberships?.length, + totalCount, offset, setPage }); + const filterOptions = [ + { + icon: , + label: "Users", + value: FilterMemberType.USERS + }, + { + icon: , + label: "Machine Identities", + value: FilterMemberType.MACHINE_IDENTITIES + } + ]; + return (
- setSearch(e.target.value)} - leftIcon={} - placeholder="Search users..." - /> +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + 0 && "border-primary/50 text-primary" + )} + > + + + + + Filter by Member Type + {filterOptions.map((option) => ( + { + e.preventDefault(); + setMemberTypeFilter((prev) => { + if (prev.includes(option.value)) { + return prev.filter((f) => f !== option.value); + } + return [...prev, option.value]; + }); + setPage(1); + }} + icon={ + memberTypeFilter.includes(option.value) && ( + + ) + } + > +
+ {option.icon} + {option.label} +
+
+ ))} +
+
+
- - @@ -158,37 +208,43 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props {isPending && } {!isPending && - filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => { - return ( - { + return userGroupMembership.type === GroupMemberType.USER ? ( + + ) : ( + ); })}
+ +
Name
Email Added On
- {Boolean(filteredGroupMemberships.length) && ( + {Boolean(totalCount) && ( )} - {!isPending && !filteredGroupMemberships?.length && ( + {!isPending && !members.length && ( )} - {!groupMemberships?.users.length && ( + {!groupMemberships?.members.length && ( {(isAllowed) => ( , + data?: object + ) => void; +}; + +export const GroupMembershipIdentityRow = ({ + identity: { + machineIdentity: { name }, + joinedGroupAt, + id + }, + handlePopUpOpen +}: Props) => { + return ( + + + + + +

{name}

+ + + +

{new Date(joinedGroupAt).toLocaleDateString()}

+
+ + + + + + + + + + + + {(isAllowed) => { + return ( +
+ } + onClick={() => + handlePopUpOpen("removeMemberFromGroup", { + memberType: GroupMemberType.MACHINE_IDENTITY, + identityId: id, + name + }) + } + isDisabled={!isAllowed} + > + Remove Identity From Group + +
+ ); + }} +
+
+
+
+ + + ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipRow.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx similarity index 75% rename from frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipRow.tsx rename to frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx index 8ec279a9d3..eb5fc45e56 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipRow.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx @@ -1,5 +1,6 @@ import { faEllipsisV, faUserMinus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UserIcon } from "lucide-react"; import { OrgPermissionCan } from "@app/components/permissions"; import { @@ -14,19 +15,23 @@ import { } from "@app/components/v2"; import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { useOidcManageGroupMembershipsEnabled } from "@app/hooks/api"; -import { TGroupUser } from "@app/hooks/api/groups/types"; +import { GroupMemberType, TGroupMemberUser } from "@app/hooks/api/groups/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { - user: TGroupUser; + user: TGroupMemberUser; handlePopUpOpen: ( popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>, data?: object ) => void; }; -export const GroupMembershipRow = ({ - user: { firstName, lastName, username, joinedGroupAt, email, id }, +export const GroupMembershipUserRow = ({ + user: { + user: { firstName, lastName, email, username }, + joinedGroupAt, + id + }, handlePopUpOpen }: Props) => { const { currentOrg } = useOrganization(); @@ -36,15 +41,18 @@ export const GroupMembershipRow = ({ return ( - -

{`${firstName ?? "-"} ${lastName ?? ""}`}

+ + - -

{email}

+ +

+ {`${firstName ?? "-"} ${lastName ?? ""}`}{" "} + ({email}) +

-

{new Date(joinedGroupAt).toLocaleDateString()}

+

{new Date(joinedGroupAt).toLocaleDateString()}

@@ -75,7 +83,12 @@ export const GroupMembershipRow = ({
} - onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })} + onClick={() => + handlePopUpOpen("removeMemberFromGroup", { + memberType: GroupMemberType.USER, + username + }) + } isDisabled={!isAllowed || isOidcManageGroupMembershipsEnabled} > Remove User From Group diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx index d6997c6cb7..1ef653dc2a 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx @@ -41,7 +41,7 @@ export const GroupProjectsSection = ({ groupId, groupSlug }: Props) => { return (
-

Projects

+

Group Projects

{(isAllowed) => ( { const navigate = useNavigate(); const { @@ -59,7 +70,8 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { setPerPage, offset, orderDirection, - toggleOrderDirection + toggleOrderDirection, + orderBy } = usePagination(GroupMembersOrderBy.Name, { initPerPage: getUserTablePreference("projectGroupMembersTable", PreferenceKey.PerPage, 20) }); @@ -79,6 +91,8 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { } }, [username]); + const [memberTypeFilter, setMemberTypeFilter] = useState([]); + const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["assumePrivileges"] as const); const handlePerPageChange = (newPerPage: number) => { @@ -89,50 +103,21 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { const { currentOrg } = useOrganization(); const { currentProject } = useProject(); - const { data: groupMemberships, isPending } = useListProjectGroupUsers({ + const { data: groupMemberships, isPending } = useListGroupMembers({ id: groupMembership.group.id, groupSlug: groupMembership.group.slug, - projectId: currentProject.id, offset, limit: perPage, search, - filter: EFilterReturnedUsers.EXISTING_MEMBERS + orderBy, + orderDirection, + memberTypeFilter: memberTypeFilter.length > 0 ? memberTypeFilter : undefined }); - const filteredGroupMemberships = useMemo(() => { - return groupMemberships && groupMemberships?.users - ? groupMemberships?.users - ?.filter((membership) => { - const userSearchString = `${membership.firstName && membership.firstName} ${ - membership.lastName && membership.lastName - } ${membership.email && membership.email} ${ - membership.username && membership.username - }`; - return userSearchString.toLowerCase().includes(search.trim().toLowerCase()); - }) - .sort((a, b) => { - const [membershipOne, membershipTwo] = - orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; - - const membershipOneComparisonString = membershipOne.firstName - ? membershipOne.firstName - : membershipOne.email; - - const membershipTwoComparisonString = membershipTwo.firstName - ? membershipTwo.firstName - : membershipTwo.email; - - const comparison = membershipOneComparisonString - .toLowerCase() - .localeCompare(membershipTwoComparisonString.toLowerCase()); - - return comparison; - }) - : []; - }, [groupMemberships, orderDirection, search]); + const { members = [], totalCount = 0 } = groupMemberships ?? {}; useResetPageHelper({ - totalCount: filteredGroupMemberships?.length, + totalCount, offset, setPage }); @@ -140,18 +125,24 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { const assumePrivileges = useAssumeProjectPrivileges(); const handleAssumePrivileges = async () => { - const { userId } = popUp?.assumePrivileges?.data as { userId: string }; + const { actorId, actorType } = popUp?.assumePrivileges?.data as { + actorId: string; + actorType: ActorType; + }; assumePrivileges.mutate( { - actorId: userId, - actorType: ActorType.USER, + actorId, + actorType, projectId: currentProject.id }, { onSuccess: () => { createNotification({ type: "success", - text: "User privilege assumption has started" + text: + actorType === ActorType.IDENTITY + ? "Machine identity privilege assumption has started" + : "User privilege assumption has started" }); const url = getProjectHomePage(currentProject.type, currentProject.environments); @@ -163,19 +154,84 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { ); }; + const filterOptions = [ + { + icon: , + label: "Users", + value: FilterMemberType.USERS + }, + { + icon: , + label: "Machine Identities", + value: FilterMemberType.MACHINE_IDENTITIES + } + ]; + return (
- setSearch(e.target.value)} - leftIcon={} - placeholder="Search users..." - /> +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + 0 && "border-primary/50 text-primary" + )} + > + + + + + Filter by Member Type + {filterOptions.map((option) => ( + { + e.preventDefault(); + setMemberTypeFilter((prev) => { + if (prev.includes(option.value)) { + return prev.filter((f) => f !== option.value); + } + return [...prev, option.value]; + }); + setPage(1); + }} + icon={ + memberTypeFilter.includes(option.value) && ( + + ) + } + > +
+ {option.icon} + {option.label} +
+
+ ))} +
+
+
- - @@ -198,42 +253,58 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { {isPending && } {!isPending && - filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => { - return ( - { + return userGroupMembership.type === GroupMemberType.USER ? ( + handlePopUpOpen("assumePrivileges", { userId })} + onAssumePrivileges={(userId) => + handlePopUpOpen("assumePrivileges", { + actorId: userId, + actorType: ActorType.USER + }) + } + /> + ) : ( + + handlePopUpOpen("assumePrivileges", { + actorId: identityId, + actorType: ActorType.IDENTITY + }) + } /> ); })}
+ +
Name {
Email Added On
- {Boolean(filteredGroupMemberships.length) && ( + {Boolean(totalCount) && ( )} - {!isPending && !filteredGroupMemberships?.length && ( + {!isPending && !members.length && ( )}
handlePopUpToggle("assumePrivileges", isOpen)} onConfirmed={handleAssumePrivileges} buttonText="Confirm" diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipIdentityRow.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipIdentityRow.tsx new file mode 100644 index 0000000000..2e00c43e51 --- /dev/null +++ b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipIdentityRow.tsx @@ -0,0 +1,81 @@ +import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { HardDriveIcon } from "lucide-react"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + IconButton, + Td, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/context"; +import { TGroupMemberMachineIdentity } from "@app/hooks/api/groups/types"; + +type Props = { + identity: TGroupMemberMachineIdentity; + onAssumePrivileges: (identityId: string) => void; +}; + +export const GroupMembershipIdentityRow = ({ + identity: { + machineIdentity: { name }, + joinedGroupAt, + id + }, + onAssumePrivileges +}: Props) => { + return ( + + + + + +

{name}

+ + + +

{new Date(joinedGroupAt).toLocaleDateString()}

+
+ + + + + + + + + + + + {(isAllowed) => { + return ( + } + onClick={() => onAssumePrivileges(id)} + isDisabled={!isAllowed} + > + Assume Privileges + + ); + }} + + + + + + + ); +}; diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipRow.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx similarity index 77% rename from frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipRow.tsx rename to frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx index 55f5664773..3288bea81b 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipRow.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembershipUserRow.tsx @@ -1,5 +1,6 @@ import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UserIcon } from "lucide-react"; import { ProjectPermissionCan } from "@app/components/permissions"; import { @@ -13,28 +14,35 @@ import { Tr } from "@app/components/v2"; import { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/context"; -import { TGroupUser } from "@app/hooks/api/groups/types"; +import { TGroupMemberUser } from "@app/hooks/api/groups/types"; type Props = { - user: TGroupUser; + user: TGroupMemberUser; onAssumePrivileges: (userId: string) => void; }; -export const GroupMembershipRow = ({ - user: { firstName, lastName, joinedGroupAt, email, id }, +export const GroupMembershipUserRow = ({ + user: { + user: { firstName, lastName, email }, + joinedGroupAt, + id + }, onAssumePrivileges }: Props) => { return ( - -

{`${firstName ?? "-"} ${lastName ?? ""}`}

+ + - -

{email}

+ +

+ {`${firstName ?? "-"} ${lastName ?? ""}`}{" "} + ({email}) +

-

{new Date(joinedGroupAt).toLocaleDateString()}

+

{new Date(joinedGroupAt).toLocaleDateString()}