Merge pull request #4995 from Infisical/feat/machine-identity-groups

feature: machine identity groups [ENG-4237]
This commit is contained in:
Piyush Gupta
2025-12-16 13:27:54 +05:30
committed by GitHub
54 changed files with 2356 additions and 454 deletions

View File

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

View File

@@ -0,0 +1,28 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
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<void> {
if (await knex.schema.hasTable(TableName.IdentityGroupMembership)) {
await knex.schema.dropTable(TableName.IdentityGroupMembership);
await dropOnUpdateTrigger(knex, TableName.IdentityGroupMembership);
}
}

View File

@@ -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<typeof IdentityGroupMembershipSchema>;
export type TIdentityGroupMembershipInsert = Omit<z.input<typeof IdentityGroupMembershipSchema>, TImmutableDBKeys>;
export type TIdentityGroupMembershipUpdate = Partial<
Omit<z.input<typeof IdentityGroupMembershipSchema>, TImmutableDBKeys>
>;

View File

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

View File

@@ -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",

View File

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

View File

@@ -56,7 +56,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
TAccessApprovalRequestReviewerDALFactory,
"create" | "find" | "findOne" | "transaction" | "delete"
>;
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleUsers">;
smtpService: Pick<TSmtpService, "sendMail">;
userDAL: Pick<
TUserDALFactory,
@@ -182,7 +182,7 @@ export const accessApprovalRequestServiceFactory = ({
await Promise.all(
approverGroupIds.map((groupApproverId) =>
groupDAL
.findAllGroupPossibleMembers({
.findAllGroupPossibleUsers({
orgId: actorOrgId,
groupId: groupApproverId
})

View File

@@ -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<typeof groupDALFactory>;
@@ -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,

View File

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

View File

@@ -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<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findUserByUsername">;
identityDAL: Pick<TIdentityDALFactory, "findOne" | "find" | "transaction">;
identityGroupMembershipDAL: Pick<TIdentityGroupMembershipDALFactory, "find" | "delete" | "insertMany">;
groupDAL: Pick<
TGroupDALFactory,
| "create"
| "findOne"
| "update"
| "delete"
| "findAllGroupPossibleUsers"
| "findAllGroupPossibleMachineIdentities"
| "findAllGroupPossibleMembers"
| "findById"
| "transaction"
| "findAllGroupProjects"
>;
membershipGroupDAL: Pick<TMembershipGroupDALFactory, "find" | "findOne" | "create">;
membershipDAL: Pick<TMembershipDALFactory, "find" | "findOne">;
membershipRoleDAL: Pick<TMembershipRoleDALFactory, "create" | "delete">;
orgDAL: Pick<TOrgDALFactory, "findMembership" | "countAllOrgMembers" | "findById">;
userGroupMembershipDAL: Pick<
@@ -65,6 +82,9 @@ type TGroupServiceFactoryDep = {
export type TGroupServiceFactory = ReturnType<typeof groupServiceFactory>;
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
};
};

View File

@@ -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<TIdentityDALFactory, "transaction">;
identityGroupMembershipDAL: Pick<TIdentityGroupMembershipDALFactory, "find" | "insertMany">;
membershipDAL: Pick<TMembershipDALFactory, "find">;
};
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<TIdentityDALFactory, "find" | "transaction">;
membershipDAL: Pick<TMembershipDALFactory, "find">;
identityGroupMembershipDAL: Pick<TIdentityGroupMembershipDALFactory, "find" | "delete">;
};
export type TConvertPendingGroupAdditionsToGroupMemberships = {
userIds: string[];
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserIdsBatch" | "transaction" | "find" | "findById">;
@@ -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"
}

View File

@@ -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<typeof identityGroupMembershipDALFactory>;
export const identityGroupMembershipDALFactory = (db: TDbClient) => {
const identityGroupMembershipOrm = ormify(db, TableName.IdentityGroupMembership);
return {
...identityGroupMembershipOrm
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

View File

@@ -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({

View File

@@ -10,7 +10,7 @@ import { TGroupDALFactory } from "../../ee/services/group/group-dal";
import { TProjectDALFactory } from "../project/project-dal";
type TGroupProjectServiceFactoryDep = {
groupDAL: Pick<TGroupDALFactory, "findOne" | "findAllGroupPossibleMembers">;
groupDAL: Pick<TGroupDALFactory, "findOne" | "findAllGroupPossibleUsers">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "findProjectGhostUser" | "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getProjectPermissionByRoles">;
};
@@ -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,

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
---
title: "Add Machine Identity to Group"
openapi: "POST /api/v1/groups/{id}/machine-identities/{machineIdentityId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "List Group Machine Identities"
openapi: "GET /api/v1/groups/{id}/machine-identities"
---

View File

@@ -0,0 +1,5 @@
---
title: "List Group Members"
openapi: "GET /api/v1/groups/{id}/members"
---

View File

@@ -0,0 +1,5 @@
---
title: "List Group Projects"
openapi: "GET /api/v1/groups/{id}/projects"
---

View File

@@ -0,0 +1,4 @@
---
title: "Remove Machine Identity from Group"
openapi: "DELETE /api/v1/groups/{id}/machine-identities/{machineIdentityId}"
---

View File

@@ -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"
]
},
{

View File

@@ -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."
---
<Info>
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.
</Info>
## 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.
<Steps>
<Step title="Creating a group">
@@ -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. Heres 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.
</Step>
<Step title="Adding users to the group">
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.
<Step title="Adding identities 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)
</Step>
<Step title="Adding the group to a project">
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.
</Step>
</Steps>
</Steps>

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 203 KiB

View File

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

View File

@@ -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<Pick<TGroupMachineIdentity, "id" | "name">>(
`/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<Pick<TGroupMachineIdentity, "id" | "name">>(
`/api/v1/groups/${groupId}/machine-identities/${identityId}`
);
return data;
},
onSuccess: (_, { slug }) => {
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupIdentitiesMemberships(slug) });
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) });
}
});
};

View File

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

View File

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

View File

@@ -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<AddMemberType>(
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 (
<Modal
isOpen={popUp?.addGroupMembers?.isOpen}
@@ -91,74 +46,78 @@ export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
}}
>
<ModalContent title="Add Group Members">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="group-users" />}
{!isPending &&
data?.users?.map(({ id, firstName, lastName, username }) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionGroupActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isPending}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddMember(username)}
>
Assign
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
<div className="mx-auto flex w-3/4 gap-x-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip
className="text-center"
content={
isOidcManageGroupMembershipsEnabled
? "OIDC Group Membership Mapping Enabled. Assign users to this group in your OIDC provider."
: undefined
}
>
<div className="flex-1">
<Button
variant="outline_bg"
onClick={() => {
setAddMemberType(AddMemberType.Users);
}}
size="xs"
isDisabled={isOidcManageGroupMembershipsEnabled}
className={twMerge(
"w-full min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600",
addMemberType === AddMemberType.Users ? "bg-mineshaft-500" : "bg-transparent"
)}
>
<div className="flex items-center gap-2">
<UserIcon size={16} />
Users
</div>
</Button>
</div>
</Tooltip>
<Button
variant="outline_bg"
onClick={() => {
setAddMemberType(AddMemberType.MachineIdentities);
}}
size="xs"
className={twMerge(
"min-w-[2.4rem] flex-1 rounded border-none hover:bg-mineshaft-600",
addMemberType === AddMemberType.MachineIdentities
? "bg-mineshaft-500"
: "bg-transparent"
)}
>
<div className="flex items-center gap-2">
<HardDriveIcon size={16} />
Machine Identities
</div>
</Button>
</div>
<div className="mt-4 mb-4 flex items-center justify-center gap-x-2">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
</div>
{addMemberType === AddMemberType.Users &&
popUpData &&
!isOidcManageGroupMembershipsEnabled && (
<AddGroupUsersTab
groupId={popUpData.groupId}
groupSlug={popUpData.slug}
search={debouncedSearch}
/>
)}
{!isPending && !data?.users?.length && (
<EmptyState
title={
debouncedSearch ? "No users match search" : "All users are already in the group"
}
icon={faUsers}
/>
)}
</TableContainer>
{addMemberType === AddMemberType.MachineIdentities && popUpData && (
<AddGroupIdentitiesTab
groupId={popUpData.groupId}
groupSlug={popUpData.slug}
search={debouncedSearch}
/>
)}
</ModalContent>
</Modal>
);

View File

@@ -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 (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Machine Identity</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="group-identities" />}
{!isPending &&
data?.machineIdentities?.map((identity: TGroupMachineIdentity) => {
return (
<Tr className="items-center" key={`group-identity-${identity.id}`}>
<Td>
<p>{identity.name}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionGroupActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isPending}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddIdentity(identity.id)}
>
Assign
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && !data?.machineIdentities?.length && (
<EmptyState
title={
search
? "No machine identities match search"
: "All machine identities are already in the group"
}
icon={faServer}
/>
)}
</TableContainer>
);
};

View File

@@ -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 (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="group-users" />}
{!isPending &&
data?.users?.map(({ id, firstName, lastName, username }) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionGroupActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isPending}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddUser(username)}
>
Assign
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && !data?.users?.length && (
<EmptyState
title={search ? "No users match search" : "All users are already in the group"}
icon={faUsers}
/>
)}
</TableContainer>
);
};

View File

@@ -0,0 +1,2 @@
export { AddGroupIdentitiesTab } from "./AddGroupIdentitiesTab";
export { AddGroupUsersTab } from "./AddGroupUsersTab";

View File

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

View File

@@ -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 (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Members</h3>
<h3 className="text-lg font-medium text-mineshaft-100">Group Members</h3>
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<Tooltip
className="text-center"
content={
isOidcManageGroupMembershipsEnabled
? "OIDC Group Membership Mapping Enabled. Assign users to this group in your OIDC provider."
: undefined
}
>
<div className="mb-4 flex items-center justify-center">
<IconButton
isDisabled={isOidcManageGroupMembershipsEnabled || !isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addGroupMembers", {
groupId,
slug: groupSlug
});
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
</Tooltip>
<div className="mb-4 flex items-center justify-center">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addGroupMembers", {
groupId,
slug: groupSlug
});
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
)}
</OrgPermissionCan>
</div>
@@ -84,21 +107,19 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => {
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<AddGroupMembersModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<AddGroupMembersModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
isOidcManageGroupMembershipsEnabled={isOidcManageGroupMembershipsEnabled}
/>
<DeleteActionModal
isOpen={popUp.removeMemberFromGroup.isOpen}
title={`Are you sure you want to remove ${
(popUp?.removeMemberFromGroup?.data as { username: string })?.username || ""
} from the group?`}
title={`Are you sure you want to remove ${getMemberName(popUp?.removeMemberFromGroup?.data)} from the group?`}
onChange={(isOpen) => 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);
}}
/>
</div>

View File

@@ -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<FilterMemberType[]>([]);
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: <UserIcon size={16} />,
label: "Users",
value: FilterMemberType.USERS
},
{
icon: <HardDriveIcon size={16} />,
label: "Machine Identities",
value: FilterMemberType.MACHINE_IDENTITIES
}
];
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search users..."
/>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Members"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
memberTypeFilter.length > 0 && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={2}
className="max-h-[70vh] thin-scrollbar overflow-y-auto"
align="end"
>
<DropdownMenuLabel>Filter by Member Type</DropdownMenuLabel>
{filterOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className="flex items-center gap-2"
iconPos="right"
onClick={(e) => {
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) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
>
<div className="flex items-center gap-2">
{option.icon}
{option.label}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">
<Th className="w-5" />
<Th className="w-1/2 pl-2">
<div className="flex items-center">
Name
<IconButton
@@ -150,7 +201,6 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
</IconButton>
</div>
</Th>
<Th>Email</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
@@ -158,37 +208,43 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
{!isPending &&
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
return (
<GroupMembershipRow
groupMemberships?.members?.map((userGroupMembership) => {
return userGroupMembership.type === GroupMemberType.USER ? (
<GroupMembershipUserRow
key={`user-group-membership-${userGroupMembership.id}`}
user={userGroupMembership}
handlePopUpOpen={handlePopUpOpen}
/>
) : (
<GroupMembershipIdentityRow
key={`identity-group-membership-${userGroupMembership.id}`}
identity={userGroupMembership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
{Boolean(totalCount) && (
<Pagination
count={filteredGroupMemberships.length}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroupMemberships?.length && (
{!isPending && !members.length && (
<EmptyState
title={
groupMemberships?.users.length
? "No users match this search..."
groupMemberships?.members.length
? "No members match this search..."
: "This group does not have any members yet"
}
icon={groupMemberships?.users.length ? faSearch : faFolder}
icon={groupMemberships?.members.length ? faSearch : faFolder}
/>
)}
{!groupMemberships?.users.length && (
{!groupMemberships?.members.length && (
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<Tooltip

View File

@@ -0,0 +1,90 @@
import { faEllipsisV, faUserMinus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { HardDriveIcon } from "lucide-react";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { GroupMemberType, TGroupMemberMachineIdentity } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
identity: TGroupMemberMachineIdentity;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>,
data?: object
) => void;
};
export const GroupMembershipIdentityRow = ({
identity: {
machineIdentity: { name },
joinedGroupAt,
id
},
handlePopUpOpen
}: Props) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td className="pr-0">
<HardDriveIcon size={20} />
</Td>
<Td className="pl-2">
<p>{name}</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => {
return (
<div>
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUserMinus} />}
onClick={() =>
handlePopUpOpen("removeMemberFromGroup", {
memberType: GroupMemberType.MACHINE_IDENTITY,
identityId: id,
name
})
}
isDisabled={!isAllowed}
>
Remove Identity From Group
</DropdownMenuItem>
</div>
);
}}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};

View File

@@ -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 (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<Td className="pr-0">
<UserIcon size={20} />
</Td>
<Td>
<p>{email}</p>
<Td className="pl-2">
<p>
{`${firstName ?? "-"} ${lastName ?? ""}`}{" "}
<span className="text-mineshaft-400">({email})</span>
</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>
@@ -75,7 +83,12 @@ export const GroupMembershipRow = ({
<div>
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUserMinus} />}
onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })}
onClick={() =>
handlePopUpOpen("removeMemberFromGroup", {
memberType: GroupMemberType.USER,
username
})
}
isDisabled={!isAllowed || isOidcManageGroupMembershipsEnabled}
>
Remove User From Group

View File

@@ -41,7 +41,7 @@ export const GroupProjectsSection = ({ groupId, groupSlug }: Props) => {
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Projects</h3>
<h3 className="text-lg font-medium text-mineshaft-100">Group Projects</h3>
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<IconButton

View File

@@ -31,7 +31,7 @@ import {
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useListGroupProjects } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { EFilterReturnedProjects } from "@app/hooks/api/groups/types";
import { FilterReturnedProjects } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { GroupProjectRow } from "./GroupProjectRow";
@@ -78,7 +78,7 @@ export const GroupProjectsTable = ({ groupId, groupSlug, handlePopUpOpen }: Prop
search: debouncedSearch,
orderBy,
orderDirection,
filter: EFilterReturnedProjects.ASSIGNED_PROJECTS
filter: FilterReturnedProjects.ASSIGNED_PROJECTS
});
const totalCount = groupMemberships?.totalCount ?? 0;

View File

@@ -1,17 +1,26 @@
import { useEffect, useMemo } from "react";
import { useEffect, useState } from "react";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { HardDriveIcon, UserIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import {
ConfirmActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
@@ -35,19 +44,21 @@ import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useAssumeProjectPrivileges } from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useListProjectGroupUsers } from "@app/hooks/api/groups/queries";
import { EFilterReturnedUsers, TGroupMembership } from "@app/hooks/api/groups/types";
import { useListGroupMembers } from "@app/hooks/api/groups/queries";
import {
FilterMemberType,
GroupMembersOrderBy,
GroupMemberType,
TGroupMembership
} from "@app/hooks/api/groups/types";
import { GroupMembershipRow } from "./GroupMembershipRow";
import { GroupMembershipIdentityRow } from "./GroupMembershipIdentityRow";
import { GroupMembershipUserRow } from "./GroupMembershipUserRow";
type Props = {
groupMembership: TGroupMembership;
};
enum GroupMembersOrderBy {
Name = "name"
}
export const GroupMembersTable = ({ groupMembership }: Props) => {
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<FilterMemberType[]>([]);
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: <UserIcon size={16} />,
label: "Users",
value: FilterMemberType.USERS
},
{
icon: <HardDriveIcon size={16} />,
label: "Machine Identities",
value: FilterMemberType.MACHINE_IDENTITIES
}
];
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search users..."
/>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Members"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
memberTypeFilter.length > 0 && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={2}
className="max-h-[70vh] thin-scrollbar overflow-y-auto"
align="end"
>
<DropdownMenuLabel>Filter by Member Type</DropdownMenuLabel>
{filterOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className="flex items-center gap-2"
iconPos="right"
onClick={(e) => {
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) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
>
<div className="flex items-center gap-2">
{option.icon}
{option.label}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">
<Th className="w-5" />
<Th className="w-1/2 pl-2">
<div className="flex items-center">
Name
<IconButton
@@ -190,7 +246,6 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
</IconButton>
</div>
</Th>
<Th>Email</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
@@ -198,42 +253,58 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
{!isPending &&
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
return (
<GroupMembershipRow
groupMemberships?.members?.map((userGroupMembership) => {
return userGroupMembership.type === GroupMemberType.USER ? (
<GroupMembershipUserRow
key={`user-group-membership-${userGroupMembership.id}`}
user={userGroupMembership}
onAssumePrivileges={(userId) => handlePopUpOpen("assumePrivileges", { userId })}
onAssumePrivileges={(userId) =>
handlePopUpOpen("assumePrivileges", {
actorId: userId,
actorType: ActorType.USER
})
}
/>
) : (
<GroupMembershipIdentityRow
key={`identity-group-membership-${userGroupMembership.id}`}
identity={userGroupMembership}
onAssumePrivileges={(identityId) =>
handlePopUpOpen("assumePrivileges", {
actorId: identityId,
actorType: ActorType.IDENTITY
})
}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
{Boolean(totalCount) && (
<Pagination
count={filteredGroupMemberships.length}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroupMemberships?.length && (
{!isPending && !members.length && (
<EmptyState
title={
groupMemberships?.users.length
? "No users match this search..."
groupMemberships?.members.length
? "No members match this search..."
: "This group does not have any members yet"
}
icon={groupMemberships?.users.length ? faSearch : faFolder}
icon={groupMemberships?.members.length ? faSearch : faFolder}
/>
)}
</TableContainer>
<ConfirmActionModal
isOpen={popUp.assumePrivileges.isOpen}
confirmKey="assume"
title="Do you want to assume privileges of this user?"
subTitle="This will set your privileges to those of the user for the next hour."
title={`Do you want to assume privileges of this ${popUp.assumePrivileges?.data?.actorType === ActorType.IDENTITY ? "machine identity" : "user"}?`}
subTitle={`This will set your privileges to those of the ${popUp.assumePrivileges?.data?.actorType === ActorType.IDENTITY ? "machine identity" : "user"} for the next hour.`}
onChange={(isOpen) => handlePopUpToggle("assumePrivileges", isOpen)}
onConfirmed={handleAssumePrivileges}
buttonText="Confirm"

View File

@@ -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 (
<Tr className="items-center" key={`group-identity-${id}`}>
<Td className="pr-0">
<HardDriveIcon size={20} />
</Td>
<Td className="pl-2">
<p>{name}</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.AssumePrivileges}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => {
return (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUser} />}
onClick={() => onAssumePrivileges(id)}
isDisabled={!isAllowed}
>
Assume Privileges
</DropdownMenuItem>
);
}}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};

View File

@@ -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 (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<Td className="pr-0">
<UserIcon size={20} />
</Td>
<Td>
<p>{email}</p>
<Td className="pl-2">
<p>
{`${firstName ?? "-"} ${lastName ?? ""}`}{" "}
<span className="text-mineshaft-400">({email})</span>
</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>