diff --git a/.gitignore b/.gitignore index af3d457e0..4a12c15f2 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,8 @@ yarn-error.log* # Infisical init .infisical.json +.infisicalignore + # Editor specific .vscode/* diff --git a/.infisicalignore b/.infisicalignore index b8fafe6db..348f9e327 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -1 +1,5 @@ .github/resources/docker-compose.be-test.yml:generic-api-key:16 +frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx:generic-api-key:206 +frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:304 +frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx:generic-api-key:206 +frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 \ No newline at end of file diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 0a9aff8f0..a4c3eea7b 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -5,6 +5,7 @@ import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-se import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -25,6 +26,7 @@ import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -89,6 +91,8 @@ declare module "fastify" { orgRole: TOrgRoleServiceFactory; superAdmin: TSuperAdminServiceFactory; user: TUserServiceFactory; + group: TGroupServiceFactory; + groupProject: TGroupProjectServiceFactory; apiKey: TApiKeyServiceFactory; project: TProjectServiceFactory; projectMembership: TProjectMembershipServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7c14eb781..2c8b8be5a 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -29,6 +29,15 @@ import { TGitAppOrg, TGitAppOrgInsert, TGitAppOrgUpdate, + TGroupProjectMembershipRoles, + TGroupProjectMembershipRolesInsert, + TGroupProjectMembershipRolesUpdate, + TGroupProjectMemberships, + TGroupProjectMembershipsInsert, + TGroupProjectMembershipsUpdate, + TGroups, + TGroupsInsert, + TGroupsUpdate, TIdentities, TIdentitiesInsert, TIdentitiesUpdate, @@ -188,6 +197,9 @@ import { TUserEncryptionKeys, TUserEncryptionKeysInsert, TUserEncryptionKeysUpdate, + TUserGroupMembership, + TUserGroupMembershipInsert, + TUserGroupMembershipUpdate, TUsers, TUsersInsert, TUsersUpdate, @@ -199,6 +211,22 @@ import { declare module "knex/types/tables" { interface Tables { [TableName.Users]: Knex.CompositeTableType; + [TableName.Groups]: Knex.CompositeTableType; + [TableName.UserGroupMembership]: Knex.CompositeTableType< + TUserGroupMembership, + TUserGroupMembershipInsert, + TUserGroupMembershipUpdate + >; + [TableName.GroupProjectMembership]: Knex.CompositeTableType< + TGroupProjectMemberships, + TGroupProjectMembershipsInsert, + TGroupProjectMembershipsUpdate + >; + [TableName.GroupProjectMembershipRole]: Knex.CompositeTableType< + TGroupProjectMembershipRoles, + TGroupProjectMembershipRolesInsert, + TGroupProjectMembershipRolesUpdate + >; [TableName.UserAliases]: Knex.CompositeTableType; [TableName.UserEncryptionKey]: Knex.CompositeTableType< TUserEncryptionKeys, diff --git a/backend/src/db/migrations/20240404182451_group.ts b/backend/src/db/migrations/20240404182451_group.ts new file mode 100644 index 000000000..53014dc53 --- /dev/null +++ b/backend/src/db/migrations/20240404182451_group.ts @@ -0,0 +1,82 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.Groups))) { + await knex.schema.createTable(TableName.Groups, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.string("name").notNullable(); + t.string("slug").notNullable(); + t.unique(["orgId", "slug"]); + t.string("role").notNullable(); + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.OrgRoles); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.Groups); + + if (!(await knex.schema.hasTable(TableName.UserGroupMembership))) { + await knex.schema.createTable(TableName.UserGroupMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); // link to user and link to groups cascade on groups + t.uuid("userId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.uuid("groupId").notNullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.UserGroupMembership); + + if (!(await knex.schema.hasTable(TableName.GroupProjectMembership))) { + await knex.schema.createTable(TableName.GroupProjectMembership, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("groupId").notNullable(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + } + await createOnUpdateTrigger(knex, TableName.GroupProjectMembership); + + if (!(await knex.schema.hasTable(TableName.GroupProjectMembershipRole))) { + await knex.schema.createTable(TableName.GroupProjectMembershipRole, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("role").notNullable(); + t.uuid("projectMembershipId").notNullable(); + t.foreign("projectMembershipId").references("id").inTable(TableName.GroupProjectMembership).onDelete("CASCADE"); + // until role is changed/removed the role should not deleted + t.uuid("customRoleId"); + t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles); + t.boolean("isTemporary").notNullable().defaultTo(false); + t.string("temporaryMode"); + t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc + t.datetime("temporaryAccessStartTime"); + t.datetime("temporaryAccessEndTime"); + t.timestamps(true, true, true); + }); + } + + await createOnUpdateTrigger(knex, TableName.GroupProjectMembershipRole); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.GroupProjectMembershipRole); + await dropOnUpdateTrigger(knex, TableName.GroupProjectMembershipRole); + + await knex.schema.dropTableIfExists(TableName.UserGroupMembership); + await dropOnUpdateTrigger(knex, TableName.UserGroupMembership); + + await knex.schema.dropTableIfExists(TableName.GroupProjectMembership); + await dropOnUpdateTrigger(knex, TableName.GroupProjectMembership); + + await knex.schema.dropTableIfExists(TableName.Groups); + await dropOnUpdateTrigger(knex, TableName.Groups); +} diff --git a/backend/src/db/schemas/group-project-membership-roles.ts b/backend/src/db/schemas/group-project-membership-roles.ts new file mode 100644 index 000000000..d837ca8e7 --- /dev/null +++ b/backend/src/db/schemas/group-project-membership-roles.ts @@ -0,0 +1,31 @@ +// 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 GroupProjectMembershipRolesSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + projectMembershipId: z.string().uuid(), + customRoleId: z.string().uuid().nullable().optional(), + isTemporary: z.boolean().default(false), + temporaryMode: z.string().nullable().optional(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TGroupProjectMembershipRoles = z.infer; +export type TGroupProjectMembershipRolesInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TGroupProjectMembershipRolesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/group-project-memberships.ts b/backend/src/db/schemas/group-project-memberships.ts new file mode 100644 index 000000000..7787a3574 --- /dev/null +++ b/backend/src/db/schemas/group-project-memberships.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const GroupProjectMembershipsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + groupId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TGroupProjectMemberships = z.infer; +export type TGroupProjectMembershipsInsert = Omit, TImmutableDBKeys>; +export type TGroupProjectMembershipsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/groups.ts b/backend/src/db/schemas/groups.ts new file mode 100644 index 000000000..9733d253e --- /dev/null +++ b/backend/src/db/schemas/groups.ts @@ -0,0 +1,23 @@ +// 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 GroupsSchema = z.object({ + id: z.string().uuid(), + orgId: z.string().uuid(), + name: z.string(), + slug: z.string(), + role: z.string(), + roleId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TGroups = z.infer; +export type TGroupsInsert = Omit, TImmutableDBKeys>; +export type TGroupsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 361ae2e0d..b9dab06ba 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -7,6 +7,9 @@ export * from "./dynamic-secret-leases"; export * from "./dynamic-secrets"; export * from "./git-app-install-sessions"; export * from "./git-app-org"; +export * from "./group-project-membership-roles"; +export * from "./group-project-memberships"; +export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; export * from "./identity-org-memberships"; @@ -61,5 +64,6 @@ export * from "./trusted-ips"; export * from "./user-actions"; export * from "./user-aliases"; export * from "./user-encryption-keys"; +export * from "./user-group-membership"; export * from "./users"; export * from "./webhooks"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 3cb11d694..d5cf1b886 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,10 @@ import { z } from "zod"; export enum TableName { Users = "users", + Groups = "groups", + GroupProjectMembership = "group_project_memberships", + GroupProjectMembershipRole = "group_project_membership_roles", + UserGroupMembership = "user_group_membership", UserAliases = "user_aliases", UserEncryptionKey = "user_encryption_keys", AuthTokens = "auth_tokens", diff --git a/backend/src/db/schemas/user-group-membership.ts b/backend/src/db/schemas/user-group-membership.ts new file mode 100644 index 000000000..b6345d85a --- /dev/null +++ b/backend/src/db/schemas/user-group-membership.ts @@ -0,0 +1,20 @@ +// 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 UserGroupMembershipSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + groupId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TUserGroupMembership = z.infer; +export type TUserGroupMembershipInsert = Omit, TImmutableDBKeys>; +export type TUserGroupMembershipUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts new file mode 100644 index 000000000..d267564f2 --- /dev/null +++ b/backend/src/ee/routes/v1/group-router.ts @@ -0,0 +1,220 @@ +import slugify from "@sindresorhus/slugify"; +import { z } from "zod"; + +import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; +import { GROUPS } from "@app/lib/api-docs"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerGroupRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + body: z.object({ + name: z.string().trim().min(1).max(50).describe(GROUPS.CREATE.name), + slug: z + .string() + .min(5) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(GROUPS.CREATE.slug), + role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(GROUPS.CREATE.role) + }), + response: { + 200: GroupsSchema + } + }, + handler: async (req) => { + const group = await server.services.group.createGroup({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return group; + } + }); + + server.route({ + url: "/:currentSlug", + method: "PATCH", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + currentSlug: z.string().trim().describe(GROUPS.UPDATE.currentSlug) + }), + body: z + .object({ + name: z.string().trim().min(1).describe(GROUPS.UPDATE.name), + slug: z + .string() + .min(5) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(GROUPS.UPDATE.slug), + role: z.string().trim().min(1).describe(GROUPS.UPDATE.role) + }) + .partial(), + response: { + 200: GroupsSchema + } + }, + handler: async (req) => { + const group = await server.services.group.updateGroup({ + currentSlug: req.params.currentSlug, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return group; + } + }); + + server.route({ + url: "/:slug", + method: "DELETE", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + slug: z.string().trim().describe(GROUPS.DELETE.slug) + }), + response: { + 200: GroupsSchema + } + }, + handler: async (req) => { + const group = await server.services.group.deleteGroup({ + groupSlug: req.params.slug, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return group; + } + }); + + server.route({ + method: "GET", + url: "/:slug/users", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + slug: z.string().trim().describe(GROUPS.LIST_USERS.slug) + }), + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).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().optional().describe(GROUPS.LIST_USERS.username) + }), + response: { + 200: z.object({ + users: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) + .merge( + z.object({ + isPartOfGroup: z.boolean() + }) + ) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { users, totalCount } = await server.services.group.listGroupUsers({ + groupSlug: req.params.slug, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + return { users, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:slug/users/:username", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + slug: z.string().trim().describe(GROUPS.ADD_USER.slug), + username: z.string().trim().describe(GROUPS.ADD_USER.username) + }), + response: { + 200: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) + } + }, + handler: async (req) => { + const user = await server.services.group.addUserToGroup({ + groupSlug: req.params.slug, + username: req.params.username, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return user; + } + }); + + server.route({ + method: "DELETE", + url: "/:slug/users/:username", + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + slug: z.string().trim().describe(GROUPS.DELETE_USER.slug), + username: z.string().trim().describe(GROUPS.DELETE_USER.username) + }), + response: { + 200: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) + } + }, + handler: async (req) => { + const user = await server.services.group.removeUserFromGroup({ + groupSlug: req.params.slug, + username: req.params.username, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return user; + } + }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 72021c81f..6860098fd 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,5 +1,6 @@ import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; +import { registerGroupRouter } from "./group-router"; import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerLdapRouter } from "./ldap-router"; import { registerLicenseRouter } from "./license-router"; @@ -53,6 +54,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); + await server.register(registerGroupRouter, { prefix: "/groups" }); await server.register( async (privilegeRouter) => { await privilegeRouter.register(registerUserAdditionalPrivilegeRouter, { prefix: "/users" }); diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index c1470e7ba..9ff1e400f 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -171,6 +171,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { req.permission.authMethod, req.permission.orgId ); + return { data: { permissions, membership } }; } }); diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 955c78c7f..80ece7e85 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -206,7 +206,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ schemas: z.array(z.string()), - userName: z.string().trim().email(), + userName: z.string().trim(), name: z.object({ familyName: z.string().trim(), givenName: z.string().trim() @@ -227,7 +227,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { 200: z.object({ schemas: z.array(z.string()), id: z.string().trim(), - userName: z.string().trim().email(), + userName: z.string().trim(), name: z.object({ familyName: z.string().trim(), givenName: z.string().trim() @@ -262,38 +262,257 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { server.route({ url: "/Users/:userId", - method: "PATCH", + method: "DELETE", schema: { params: z.object({ userId: z.string().trim() }), - body: z.object({ - schemas: z.array(z.string()), - Operations: z.array( - z.object({ - op: z.string().trim(), - path: z.string().trim().optional(), - value: z.union([ - z.object({ - active: z.boolean() - }), - z.string().trim() - ]) - }) - ) - }), response: { 200: z.object({}) } }, onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), handler: async (req) => { - const user = await req.server.services.scim.updateScimUser({ + const user = await req.server.services.scim.deleteScimUser({ userId: req.params.userId, + orgId: req.permission.orgId + }); + + return user; + } + }); + + server.route({ + url: "/Groups", + method: "POST", + schema: { + body: z.object({ + schemas: z.array(z.string()), + displayName: z.string().trim(), + members: z.array(z.any()).length(0).optional() // okta-specific + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array(z.any()).length(0), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const group = await req.server.services.scim.createScimGroup({ + displayName: req.body.displayName, + orgId: req.permission.orgId + }); + + return group; + } + }); + + server.route({ + url: "/Groups", + method: "GET", + schema: { + querystring: z.object({ + startIndex: z.coerce.number().default(1), + count: z.coerce.number().default(20), + filter: z.string().trim().optional() + }), + response: { + 200: z.object({ + Resources: z.array( + z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array(z.any()).length(0), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + ), + itemsPerPage: z.number(), + schemas: z.array(z.string()), + startIndex: z.number(), + totalResults: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const groups = await req.server.services.scim.listScimGroups({ + orgId: req.permission.orgId, + offset: req.query.startIndex, + limit: req.query.count + }); + + return groups; + } + }); + + server.route({ + url: "/Groups/:groupId", + method: "GET", + schema: { + params: z.object({ + groupId: z.string().trim() + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array( + z.object({ + value: z.string(), + display: z.string() + }) + ), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const group = await req.server.services.scim.getScimGroup({ + groupId: req.params.groupId, + orgId: req.permission.orgId + }); + return group; + } + }); + + server.route({ + url: "/Groups/:groupId", + method: "PUT", + schema: { + params: z.object({ + groupId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array(z.any()).length(0) + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array( + z.object({ + value: z.string(), + display: z.string() + }) + ), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const group = await req.server.services.scim.updateScimGroupNamePut({ + groupId: req.params.groupId, + orgId: req.permission.orgId, + displayName: req.body.displayName + }); + + return group; + } + }); + + server.route({ + url: "/Groups/:groupId", + method: "PATCH", + schema: { + params: z.object({ + groupId: z.string().trim() + }), + body: z.object({ + schemas: z.array(z.string()), + Operations: z.array( + z.union([ + z.object({ + op: z.literal("replace"), + value: z.object({ + id: z.string().trim(), + displayName: z.string().trim() + }) + }), + z.object({ + op: z.literal("remove"), + path: z.string().trim() + }), + z.object({ + op: z.literal("add"), + value: z.object({ + value: z.string().trim(), + display: z.string().trim().optional() + }) + }) + ]) + ) + }), + response: { + 200: z.object({ + schemas: z.array(z.string()), + id: z.string().trim(), + displayName: z.string().trim(), + members: z.array( + z.object({ + value: z.string(), + display: z.string() + }) + ), + meta: z.object({ + resourceType: z.string().trim() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + // console.log("PATCH /Groups/:groupId req.body: ", req.body); + // console.log("PATCH /Groups/:groupId req.body: ", req.body.Operations[0]); + const group = await req.server.services.scim.updateScimGroupNamePatch({ + groupId: req.params.groupId, orgId: req.permission.orgId, operations: req.body.Operations }); - return user; + + return group; + } + }); + + server.route({ + url: "/Groups/:groupId", + method: "DELETE", + schema: { + params: z.object({ + groupId: z.string().trim() + }), + response: { + 200: z.object({}) + } + }, + onRequest: verifyAuth([AuthMode.SCIM_TOKEN]), + handler: async (req) => { + const group = await req.server.services.scim.deleteScimGroup({ + groupId: req.params.groupId, + orgId: req.permission.orgId + }); + + return group; } }); diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts new file mode 100644 index 000000000..55afd4e10 --- /dev/null +++ b/backend/src/ee/services/group/group-dal.ts @@ -0,0 +1,157 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TGroups } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; + +export type TGroupDALFactory = ReturnType; + +export const groupDALFactory = (db: TDbClient) => { + const groupOrm = ormify(db, TableName.Groups); + + const findGroups = async (filter: TFindFilter, { offset, limit, sort, tx }: TFindOpt = {}) => { + try { + const query = (tx || db)(TableName.Groups) + // eslint-disable-next-line + .where(buildFindFilter(filter)) + .select(selectAllTableCols(TableName.Groups)); + + if (limit) void query.limit(limit); + if (offset) void query.limit(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + + const res = await query; + return res; + } catch (err) { + throw new DatabaseError({ error: err, name: "Find groups" }); + } + }; + + const findByOrgId = async (orgId: string, tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.Groups) + .where(`${TableName.Groups}.orgId`, orgId) + .leftJoin(TableName.OrgRoles, `${TableName.Groups}.roleId`, `${TableName.OrgRoles}.id`) + .select(selectAllTableCols(TableName.Groups)) + // cr stands for custom role + .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) + .select(db.ref("name").as("crName").withSchema(TableName.OrgRoles)) + .select(db.ref("slug").as("crSlug").withSchema(TableName.OrgRoles)) + .select(db.ref("description").as("crDescription").withSchema(TableName.OrgRoles)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)); + return docs.map(({ crId, crDescription, crSlug, crPermission, crName, ...el }) => ({ + ...el, + customRole: el.roleId + ? { + id: crId, + name: crName, + slug: crSlug, + permissions: crPermission, + description: crDescription + } + : undefined + })); + } catch (error) { + throw new DatabaseError({ error, name: "FindByOrgId" }); + } + }; + + const countAllGroupMembers = async ({ orgId, groupId }: { orgId: string; groupId: string }) => { + try { + interface CountResult { + count: string; + } + + const doc = await db(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.UserGroupMembership, function () { + this.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn( + `${TableName.UserGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .where({ isGhost: false }) + .count(`${TableName.Users}.id`) + .first(); + + return parseInt((doc?.count as string) || "0", 10); + } catch (err) { + throw new DatabaseError({ error: err, name: "Count all group members" }); + } + }; + + // special query + const findAllGroupMembers = async ({ + orgId, + groupId, + offset = 0, + limit, + username + }: { + orgId: string; + groupId: string; + offset?: number; + limit?: number; + username?: string; + }) => { + try { + let query = db(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.UserGroupMembership, function () { + this.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn( + `${TableName.UserGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("groupId").withSchema(TableName.UserGroupMembership), + 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.ref("id").withSchema(TableName.Users).as("userId") + ) + .where({ isGhost: false }) + .offset(offset); + + if (limit) { + query = query.limit(limit); + } + + if (username) { + query = query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); + } + + const members = await query; + + return members.map( + ({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({ + id: userId, + email, + username: memberUsername, + firstName, + lastName, + isPartOfGroup: !!memberGroupId + }) + ); + } catch (error) { + throw new DatabaseError({ error, name: "Find all org members" }); + } + }; + + return { + findGroups, + findByOrgId, + countAllGroupMembers, + findAllGroupMembers, + ...groupOrm + }; +}; diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts new file mode 100644 index 000000000..285403bcd --- /dev/null +++ b/backend/src/ee/services/group/group-service.ts @@ -0,0 +1,474 @@ +import { ForbiddenError } from "@casl/ability"; +import slugify from "@sindresorhus/slugify"; + +import { OrgMembershipRole, SecretKeyEncoding, TOrgRoles } from "@app/db/schemas"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { decryptAsymmetric, encryptAsymmetric, infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { TGroupProjectDALFactory } from "../../../services/group-project/group-project-dal"; +import { TOrgDALFactory } from "../../../services/org/org-dal"; +import { TProjectDALFactory } from "../../../services/project/project-dal"; +import { TProjectBotDALFactory } from "../../../services/project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "../../../services/project-key/project-key-dal"; +import { TUserDALFactory } from "../../../services/user/user-dal"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { TPermissionServiceFactory } from "../permission/permission-service"; +import { TGroupDALFactory } from "./group-dal"; +import { + TAddUserToGroupDTO, + TCreateGroupDTO, + TDeleteGroupDTO, + TListGroupUsersDTO, + TRemoveUserFromGroupDTO, + TUpdateGroupDTO +} from "./group-types"; +import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; + +type TGroupServiceFactoryDep = { + userDAL: Pick; + groupDAL: Pick< + TGroupDALFactory, + "create" | "findOne" | "update" | "delete" | "findAllGroupMembers" | "countAllGroupMembers" + >; + groupProjectDAL: Pick; + orgDAL: Pick; + userGroupMembershipDAL: Pick< + TUserGroupMembershipDALFactory, + "findOne" | "create" | "delete" | "filterProjectsByUserMembership" + >; + projectDAL: Pick; + projectBotDAL: Pick; + projectKeyDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TGroupServiceFactory = ReturnType; + +export const groupServiceFactory = ({ + userDAL, + groupDAL, + groupProjectDAL, + orgDAL, + userGroupMembershipDAL, + projectDAL, + projectBotDAL, + projectKeyDAL, + permissionService, + licenseService +}: TGroupServiceFactoryDep) => { + const createGroup = async ({ name, slug, role, actor, actorId, actorAuthMethod, actorOrgId }: TCreateGroupDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Groups); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to create group due to plan restriction. Upgrade plan to create group." + }); + + const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole( + role, + actorOrgId + ); + const isCustomRole = Boolean(customRole); + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasRequiredPriviledges) throw new BadRequestError({ message: "Failed to create a more privileged group" }); + + const group = await groupDAL.create({ + name, + slug: slug || slugify(`${name}-${alphaNumericNanoId(4)}`), + orgId: actorOrgId, + role: isCustomRole ? OrgMembershipRole.Custom : role, + roleId: customRole?.id + }); + + return group; + }; + + const updateGroup = async ({ + currentSlug, + name, + slug, + role, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateGroupDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to update group due to plan restrictio Upgrade plan to update group." + }); + + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: currentSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${currentSlug}` }); + + let customRole: TOrgRoles | undefined; + if (role) { + const { permission: rolePermission, role: customOrgRole } = await permissionService.getOrgPermissionByRole( + role, + group.orgId + ); + + const isCustomRole = Boolean(customOrgRole); + const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasRequiredNewRolePermission) + throw new BadRequestError({ message: "Failed to create a more privileged group" }); + if (isCustomRole) customRole = customOrgRole; + } + + const [updatedGroup] = await groupDAL.update( + { + orgId: actorOrgId, + slug: currentSlug + }, + { + name, + slug: slug ? slugify(slug) : undefined, + ...(role + ? { + role: customRole ? OrgMembershipRole.Custom : role, + roleId: customRole?.id ?? null + } + : {}) + } + ); + + return updatedGroup; + }; + + const deleteGroup = async ({ groupSlug, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteGroupDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Groups); + + const plan = await licenseService.getPlan(actorOrgId); + + if (!plan.groups) + throw new BadRequestError({ + message: "Failed to delete group due to plan restriction. Upgrade plan to delete group." + }); + + const [group] = await groupDAL.delete({ + orgId: actorOrgId, + slug: groupSlug + }); + + return group; + }; + + const listGroupUsers = async ({ + groupSlug, + offset, + limit, + username, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListGroupUsersDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + + const group = await groupDAL.findOne({ + orgId: actorOrgId, + slug: groupSlug + }); + + if (!group) + throw new BadRequestError({ + message: `Failed to find group with slug ${groupSlug}` + }); + + const users = await groupDAL.findAllGroupMembers({ + orgId: group.orgId, + groupId: group.id, + offset, + limit, + username + }); + + const totalCount = await groupDAL.countAllGroupMembers({ + orgId: group.orgId, + groupId: group.id + }); + + return { users, totalCount }; + }; + + const addUserToGroup = async ({ + groupSlug, + username, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TAddUserToGroupDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + + // check if group with slug exists + const group = await groupDAL.findOne({ + orgId: actorOrgId, + slug: groupSlug + }); + + if (!group) + throw new BadRequestError({ + message: `Failed to find group with slug ${groupSlug}` + }); + + const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); + + // check if user has broader or equal to privileges than group + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); + + // get user with username + const user = await userDAL.findUserEncKeyByUsername({ + username + }); + + if (!user) + throw new BadRequestError({ + message: `Failed to find user with username ${username}` + }); + + // check if user group membership already exists + const existingUserGroupMembership = await userGroupMembershipDAL.findOne({ + groupId: group.id, + userId: user.userId + }); + + if (existingUserGroupMembership) + throw new BadRequestError({ + message: `User ${username} is already part of the group ${groupSlug}` + }); + + // check if user is even part of the organization + const existingUserOrgMembership = await orgDAL.findMembership({ + userId: user.userId, + orgId: actorOrgId + }); + + if (!existingUserOrgMembership) + throw new BadRequestError({ + message: `User ${username} is not part of the organization` + }); + + await userGroupMembershipDAL.create({ + userId: user.userId, + groupId: group.id + }); + + // check which projects the group is part of + const projectIds = ( + await groupProjectDAL.find({ + groupId: group.id + }) + ).map((gp) => gp.projectId); + + const keys = await projectKeyDAL.find({ + receiverId: user.userId, + $in: { + projectId: projectIds + } + }); + + const keysSet = new Set(keys.map((k) => k.projectId)); + const projectsToAddKeyFor = projectIds.filter((p) => !keysSet.has(p)); + + for await (const projectId of projectsToAddKeyFor) { + const ghostUser = await projectDAL.findProjectGhostUser(projectId); + + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); + + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId }); + + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const plaintextProjectKey = decryptAsymmetric({ + ciphertext: ghostUserLatestKey.encryptedKey, + nonce: ghostUserLatestKey.nonce, + publicKey: ghostUserLatestKey.sender.publicKey, + privateKey: botPrivateKey + }); + + const { ciphertext: encryptedKey, nonce } = encryptAsymmetric(plaintextProjectKey, user.publicKey, botPrivateKey); + + await projectKeyDAL.create({ + encryptedKey, + nonce, + senderId: ghostUser.id, + receiverId: user.userId, + projectId + }); + } + + return user; + }; + + const removeUserFromGroup = async ({ + groupSlug, + username, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TRemoveUserFromGroupDTO) => { + if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + + // check if group with slug exists + const group = await groupDAL.findOne({ + orgId: actorOrgId, + slug: groupSlug + }); + + if (!group) + throw new BadRequestError({ + message: `Failed to find group with slug ${groupSlug}` + }); + + const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); + + // check if user has broader or equal to privileges than group + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, groupRolePermission); + if (!hasRequiredPriviledges) + throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); + + const user = await userDAL.findOne({ + username + }); + + if (!user) + throw new BadRequestError({ + message: `Failed to find user with username ${username}` + }); + + // check if user group membership already exists + const existingUserGroupMembership = await userGroupMembershipDAL.findOne({ + groupId: group.id, + userId: user.id + }); + + if (!existingUserGroupMembership) + throw new BadRequestError({ + message: `User ${username} is not part of the group ${groupSlug}` + }); + + const projectIds = ( + await groupProjectDAL.find({ + groupId: group.id + }) + ).map((gp) => gp.projectId); + + const t = await userGroupMembershipDAL.filterProjectsByUserMembership(user.id, group.id, projectIds); + + const projectsToDeleteKeyFor = projectIds.filter((p) => !t.has(p)); + + if (projectsToDeleteKeyFor.length) { + await projectKeyDAL.delete({ + receiverId: user.id, + $in: { + projectId: projectsToDeleteKeyFor + } + }); + } + + await userGroupMembershipDAL.delete({ + groupId: group.id, + userId: user.id + }); + + return user; + }; + + return { + createGroup, + updateGroup, + deleteGroup, + listGroupUsers, + addUserToGroup, + removeUserFromGroup + }; +}; diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts new file mode 100644 index 000000000..e2fbbe63e --- /dev/null +++ b/backend/src/ee/services/group/group-types.ts @@ -0,0 +1,37 @@ +import { TGenericPermission } from "@app/lib/types"; + +export type TCreateGroupDTO = { + name: string; + slug?: string; + role: string; +} & TGenericPermission; + +export type TUpdateGroupDTO = { + currentSlug: string; +} & Partial<{ + name: string; + slug: string; + role: string; +}> & + TGenericPermission; + +export type TDeleteGroupDTO = { + groupSlug: string; +} & TGenericPermission; + +export type TListGroupUsersDTO = { + groupSlug: string; + offset: number; + limit: number; + username?: string; +} & TGenericPermission; + +export type TAddUserToGroupDTO = { + groupSlug: string; + username: string; +} & TGenericPermission; + +export type TRemoveUserFromGroupDTO = { + groupSlug: string; + username: string; +} & TGenericPermission; diff --git a/backend/src/ee/services/group/user-group-membership-dal.ts b/backend/src/ee/services/group/user-group-membership-dal.ts new file mode 100644 index 000000000..e8a262c3e --- /dev/null +++ b/backend/src/ee/services/group/user-group-membership-dal.ts @@ -0,0 +1,125 @@ +import { TDbClient } from "@app/db"; +import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TUserGroupMembershipDALFactory = ReturnType; + +export const userGroupMembershipDALFactory = (db: TDbClient) => { + const userGroupMembershipOrm = ormify(db, TableName.UserGroupMembership); + + /** + * Returns a sub-set of projectIds fed into this function corresponding to projects where either: + * - The user is a direct member of the project. + * - The user is a member of a group that is a member of the project, excluding projects that they are part of + * through the group with id [groupId]. + */ + const filterProjectsByUserMembership = async (userId: string, groupId: string, projectIds: string[]) => { + const userProjectMemberships: string[] = await db(TableName.ProjectMembership) + .where(`${TableName.ProjectMembership}.userId`, userId) + .whereIn(`${TableName.ProjectMembership}.projectId`, projectIds) + .pluck(`${TableName.ProjectMembership}.projectId`); + + const userGroupMemberships: string[] = await db(TableName.UserGroupMembership) + .where(`${TableName.UserGroupMembership}.userId`, userId) + .whereNot(`${TableName.UserGroupMembership}.groupId`, groupId) + .join( + TableName.GroupProjectMembership, + `${TableName.UserGroupMembership}.groupId`, + `${TableName.GroupProjectMembership}.groupId` + ) + .whereIn(`${TableName.GroupProjectMembership}.projectId`, projectIds) + .pluck(`${TableName.GroupProjectMembership}.projectId`); + + return new Set(userProjectMemberships.concat(userGroupMemberships)); + }; + + // special query + const findUserGroupMembershipsInProject = async (usernames: string[], projectId: string) => { + try { + const usernameDocs: string[] = await db(TableName.UserGroupMembership) + .join( + TableName.GroupProjectMembership, + `${TableName.UserGroupMembership}.groupId`, + `${TableName.GroupProjectMembership}.groupId` + ) + .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .whereIn(`${TableName.Users}.username`, usernames) // TODO: pluck usernames + .pluck(`${TableName.Users}.id`); + + return usernameDocs; + } catch (error) { + throw new DatabaseError({ error, name: "Find user group members in project" }); + } + }; + + /** + * Return list of users that are part of the group with id [groupId] + * that have not yet been added individually to project with id [projectId]. + * + * Note: Filters out users that are part of other groups in the project. + * @param groupId + * @param projectId + * @returns + */ + const findGroupMembersNotInProject = async (groupId: string, projectId: string) => { + try { + // get list of groups in the project with id [projectId] + // that that are not the group with id [groupId] + const groups: string[] = await db(TableName.GroupProjectMembership) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .whereNot(`${TableName.GroupProjectMembership}.groupId`, groupId) + .pluck(`${TableName.GroupProjectMembership}.groupId`); + + // main query + const members = await db(TableName.UserGroupMembership) + .where(`${TableName.UserGroupMembership}.groupId`, groupId) + .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.ProjectMembership, function () { + this.on(`${TableName.Users}.id`, "=", `${TableName.ProjectMembership}.userId`).andOn( + `${TableName.ProjectMembership}.projectId`, + "=", + db.raw("?", [projectId]) + ); + }) + .whereNull(`${TableName.ProjectMembership}.userId`) + .leftJoin( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select( + db.ref("id").withSchema(TableName.UserGroupMembership), + db.ref("groupId").withSchema(TableName.UserGroupMembership), + 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.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + ) + .where({ isGhost: false }) // MAKE SURE USER IS NOT A GHOST USER + .whereNotIn(`${TableName.UserGroupMembership}.userId`, function () { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.select(`${TableName.UserGroupMembership}.userId`) + .from(TableName.UserGroupMembership) + .whereIn(`${TableName.UserGroupMembership}.groupId`, groups); + }); + + return members.map(({ email, username, firstName, lastName, userId, publicKey, ...data }) => ({ + ...data, + user: { email, username, firstName, lastName, id: userId, publicKey } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find group members not in project" }); + } + }; + + return { + ...userGroupMembershipOrm, + filterProjectsByUserMembership, + findUserGroupMembershipsInProject, + findGroupMembersNotInProject + }; +}; diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 0316052cd..76e2d40ce 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -12,7 +12,6 @@ import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; -import { TOrgPermission } from "@app/lib/types"; import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TOrgBotDALFactory } from "@app/services/org/org-bot-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; @@ -24,7 +23,7 @@ import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { TLdapConfigDALFactory } from "./ldap-config-dal"; -import { TCreateLdapCfgDTO, TLdapLoginDTO, TUpdateLdapCfgDTO } from "./ldap-config-types"; +import { TCreateLdapCfgDTO, TGetLdapCfgDTO, TLdapLoginDTO, TUpdateLdapCfgDTO } from "./ldap-config-types"; type TLdapConfigServiceFactoryDep = { ldapConfigDAL: TLdapConfigDALFactory; @@ -282,7 +281,7 @@ export const ldapConfigServiceFactory = ({ orgId, actorAuthMethod, actorOrgId - }: TOrgPermission) => { + }: TGetLdapCfgDTO) => { const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); return getLdapCfg({ diff --git a/backend/src/ee/services/ldap-config/ldap-config-types.ts b/backend/src/ee/services/ldap-config/ldap-config-types.ts index 025ce7781..4e261f9e9 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-types.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-types.ts @@ -1,6 +1,7 @@ import { TOrgPermission } from "@app/lib/types"; export type TCreateLdapCfgDTO = { + orgId: string; isActive: boolean; url: string; bindDN: string; @@ -9,7 +10,9 @@ export type TCreateLdapCfgDTO = { caCert: string; } & TOrgPermission; -export type TUpdateLdapCfgDTO = Partial<{ +export type TUpdateLdapCfgDTO = { + orgId: string; +} & Partial<{ isActive: boolean; url: string; bindDN: string; @@ -19,6 +22,10 @@ export type TUpdateLdapCfgDTO = Partial<{ }> & TOrgPermission; +export type TGetLdapCfgDTO = { + orgId: string; +} & TOrgPermission; + export type TLdapLoginDTO = { externalId: string; username: string; diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/licence-fns.ts index 8f52939c5..b5cbf103e 100644 --- a/backend/src/ee/services/license/__mocks__/licence-fns.ts +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -20,6 +20,7 @@ export const getDefaultOnPremFeatures = () => { samlSSO: false, scim: false, ldap: false, + groups: false, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 104d1465b..8a4de57f1 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -27,6 +27,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ samlSSO: false, scim: false, ldap: false, + groups: false, status: null, trial_end: null, has_used_trial: true, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 735d8458e..1cea39a83 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -43,6 +43,7 @@ export type TFeatureSet = { samlSSO: false; scim: false; ldap: false; + groups: false; status: null; trial_end: null; has_used_trial: true; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 30b601c2c..9fece040b 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -18,6 +18,7 @@ export enum OrgPermissionSubjects { Sso = "sso", Scim = "scim", Ldap = "ldap", + Groups = "groups", Billing = "billing", SecretScanning = "secret-scanning", Identity = "identity" @@ -33,6 +34,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] + | [OrgPermissionActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity]; @@ -83,6 +85,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Ldap); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Groups); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Groups); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Groups); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); @@ -105,6 +112,7 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); can(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index a8072c0d3..b89a3aa3d 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -45,6 +45,43 @@ export const permissionDALFactory = (db: TDbClient) => { const getProjectPermission = async (userId: string, projectId: string) => { try { + const groups: string[] = await db(TableName.GroupProjectMembership) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .pluck(`${TableName.GroupProjectMembership}.groupId`); + + const groupDocs = await db(TableName.UserGroupMembership) + .where(`${TableName.UserGroupMembership}.userId`, userId) + .whereIn(`${TableName.UserGroupMembership}.groupId`, groups) + .join( + TableName.GroupProjectMembership, + `${TableName.GroupProjectMembership}.groupId`, + `${TableName.UserGroupMembership}.groupId` + ) + .join( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.GroupProjectMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) + .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) + .select(selectAllTableCols(TableName.GroupProjectMembershipRole)) + .select( + db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"), + // TODO(roll-forward-migration): remove this field when we drop this in next migration after a week + db.ref("createdAt").withSchema(TableName.GroupProjectMembership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.GroupProjectMembership).as("membershipUpdatedAt"), + db.ref("projectId").withSchema(TableName.GroupProjectMembership), + db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("orgId").withSchema(TableName.Project), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug") + ) + .select("permissions"); + const docs = await db(TableName.ProjectMembership) .join( TableName.ProjectUserMembershipRole, @@ -69,9 +106,9 @@ export const permissionDALFactory = (db: TDbClient) => { .select( db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"), // TODO(roll-forward-migration): remove this field when we drop this in next migration after a week - db.ref("role").withSchema(TableName.ProjectMembership).as("oldRoleField"), db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"), db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"), + db.ref("projectId").withSchema(TableName.ProjectMembership), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("orgId").withSchema(TableName.Project), db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), @@ -93,19 +130,12 @@ export const permissionDALFactory = (db: TDbClient) => { const permission = sqlNestRelationships({ data: docs, - key: "membershipId", - parentMapper: ({ - orgId, - orgAuthEnforced, - membershipId, - membershipCreatedAt, - membershipUpdatedAt, - oldRoleField - }) => ({ + key: "projectId", + parentMapper: ({ orgId, orgAuthEnforced, membershipId, membershipCreatedAt, membershipUpdatedAt, role }) => ({ orgId, orgAuthEnforced, userId, - role: oldRoleField, + role, id: membershipId, projectId, createdAt: membershipCreatedAt, @@ -145,19 +175,66 @@ export const permissionDALFactory = (db: TDbClient) => { ] }); - if (!permission?.[0]) return undefined; + const groupPermission = groupDocs.length + ? sqlNestRelationships({ + data: groupDocs, + key: "projectId", + parentMapper: ({ + orgId, + orgAuthEnforced, + membershipId, + membershipCreatedAt, + membershipUpdatedAt, + role + }) => ({ + orgId, + orgAuthEnforced, + userId, + role, + id: membershipId, + projectId, + createdAt: membershipCreatedAt, + updatedAt: membershipUpdatedAt + }), + childrenMapper: [ + { + key: "id", + label: "roles" as const, + mapper: (data) => + ProjectUserMembershipRolesSchema.extend({ + permissions: z.unknown(), + customRoleSlug: z.string().optional().nullable() + }).parse(data) + } + ] + }) + : []; + + if (!permission?.[0] && !groupPermission[0]) return undefined; + // when introducting cron mode change it here - const activeRoles = permission?.[0]?.roles?.filter( - ({ isTemporary, temporaryAccessEndTime }) => - !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) - ); + const activeRoles = + permission?.[0]?.roles?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) ?? []; + + const activeGroupRoles = + groupPermission?.[0]?.roles?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) ?? []; const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter( ({ isTemporary, temporaryAccessEndTime }) => !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) ); - return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges }; + return { + ...(permission[0] || groupPermission[0]), + roles: [...activeRoles, ...activeGroupRoles], + additionalPrivileges: activeAdditionalPrivileges + }; } catch (error) { throw new DatabaseError({ error, name: "GetProjectPermission" }); } diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 46dbdcc3b..b24024bd4 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -12,6 +12,7 @@ export enum ProjectPermissionActions { export enum ProjectPermissionSub { Role = "role", Member = "member", + Groups = "groups", Settings = "settings", Integrations = "integrations", Webhooks = "webhooks", @@ -41,6 +42,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] | [ProjectPermissionActions, ProjectPermissionSub.Member] + | [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] @@ -82,6 +84,11 @@ const buildAdminPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); can(ProjectPermissionActions.Create, ProjectPermissionSub.Role); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); @@ -157,6 +164,8 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionActions.Create, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); @@ -209,6 +218,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionActions.Read, ProjectPermissionSub.Member); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index 8b68870da..e816cffcf 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -1,4 +1,4 @@ -import { TListScimUsers, TScimUser } from "./scim-types"; +import { TListScimGroups, TListScimUsers, TScimGroup, TScimUser } from "./scim-types"; export const buildScimUserList = ({ scimUsers, @@ -62,3 +62,47 @@ export const buildScimUser = ({ return scimUser; }; + +export const buildScimGroupList = ({ + scimGroups, + offset, + limit +}: { + scimGroups: TScimGroup[]; + offset: number; + limit: number; +}): TListScimGroups => { + return { + Resources: scimGroups, + itemsPerPage: limit, + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + startIndex: offset, + totalResults: scimGroups.length + }; +}; + +export const buildScimGroup = ({ + groupId, + name, + members +}: { + groupId: string; + name: string; + members: { + value: string; + display: string; + }[]; +}): TScimGroup => { + const scimGroup = { + schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], + id: groupId, + displayName: name, + members, + meta: { + resourceType: "Group", + location: null + } + }; + + return scimGroup; +}; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 6ed594777..15ca67a10 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -1,10 +1,13 @@ import { ForbiddenError } from "@casl/ability"; +import slugify from "@sindresorhus/slugify"; import jwt from "jsonwebtoken"; -import { OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { OrgMembershipRole, OrgMembershipStatus, TableName, TGroups } from "@app/db/schemas"; +import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TOrgPermission } from "@app/lib/types"; import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TOrgDALFactory } from "@app/services/org/org-dal"; @@ -17,16 +20,23 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { buildScimUser, buildScimUserList } from "./scim-fns"; +import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList } from "./scim-fns"; import { + TCreateScimGroupDTO, TCreateScimTokenDTO, TCreateScimUserDTO, + TDeleteScimGroupDTO, TDeleteScimTokenDTO, + TDeleteScimUserDTO, + TGetScimGroupDTO, TGetScimUserDTO, + TListScimGroupsDTO, TListScimUsers, TListScimUsersDTO, TReplaceScimUserDTO, TScimTokenJwtPayload, + TUpdateScimGroupNamePatchDTO, + TUpdateScimGroupNamePutDTO, TUpdateScimUserDTO } from "./scim-types"; @@ -39,6 +49,7 @@ type TScimServiceFactoryDep = { >; projectDAL: Pick; projectMembershipDAL: Pick; + groupDAL: Pick; licenseService: Pick; permissionService: Pick; smtpService: TSmtpService; @@ -53,6 +64,7 @@ export const scimServiceFactory = ({ orgDAL, projectDAL, projectMembershipDAL, + groupDAL, permissionService, smtpService }: TScimServiceFactoryDep) => { @@ -423,6 +435,221 @@ export const scimServiceFactory = ({ }); }; + const deleteScimUser = async ({ userId, orgId }: TDeleteScimUserDTO) => { + const [membership] = await orgDAL + .findMembership({ + userId, + [`${TableName.OrgMembership}.orgId` as "id"]: orgId + }) + .catch(() => { + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + }); + + if (!membership) + throw new ScimRequestError({ + detail: "User not found", + status: 404 + }); + + if (!membership.scimEnabled) { + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + } + + await deleteOrgMembership({ + orgMembershipId: membership.id, + orgId: membership.orgId, + orgDAL, + projectDAL, + projectMembershipDAL + }); + + return {}; // intentionally return empty object upon success + }; + + const listScimGroups = async ({ orgId, offset, limit }: TListScimGroupsDTO) => { + const org = await orgDAL.findById(orgId); + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const groups = await groupDAL.findGroups({ + orgId + }); + + const scimGroups = groups.map((group) => + buildScimGroup({ + groupId: group.id, + name: group.name, + members: [] + }) + ); + + return buildScimGroupList({ + scimGroups, + offset, + limit + }); + }; + + const createScimGroup = async ({ displayName, orgId }: TCreateScimGroupDTO) => { + const org = await orgDAL.findById(orgId); + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + const group = await groupDAL.create({ + name: displayName, + slug: slugify(`${displayName}-${alphaNumericNanoId(4)}`), + orgId, + role: OrgMembershipRole.NoAccess + }); + + return buildScimGroup({ + groupId: group.id, + name: group.name, + members: [] + }); + }; + + const getScimGroup = async ({ groupId, orgId }: TGetScimGroupDTO) => { + const group = await groupDAL.findOne({ + id: groupId, + orgId + }); + + if (!group) { + throw new ScimRequestError({ + detail: "Group Not Found", + status: 404 + }); + } + + const users = await groupDAL.findAllGroupMembers({ + orgId: group.orgId, + groupId: group.id + }); + + return buildScimGroup({ + groupId: group.id, + name: group.name, + members: users + .filter((user) => user.isPartOfGroup) + .map((user) => ({ + value: user.id, + display: `${user.firstName} ${user.lastName}` + })) + }); + }; + + const updateScimGroupNamePut = async ({ groupId, orgId, displayName }: TUpdateScimGroupNamePutDTO) => { + const [group] = await groupDAL.update( + { + id: groupId, + orgId + }, + { + name: displayName + } + ); + + if (!group) { + throw new ScimRequestError({ + detail: "Group Not Found", + status: 404 + }); + } + + return buildScimGroup({ + groupId: group.id, + name: group.name, + members: [] + }); + }; + + // TODO: add support for add/remove op + const updateScimGroupNamePatch = async ({ groupId, orgId, operations }: TUpdateScimGroupNamePatchDTO) => { + const org = await orgDAL.findById(orgId); + + if (!org.scimEnabled) + throw new ScimRequestError({ + detail: "SCIM is disabled for the organization", + status: 403 + }); + + let group: TGroups | undefined; + for await (const operation of operations) { + switch (operation.op) { + case "replace": { + await groupDAL.update( + { + id: groupId, + orgId + }, + { + name: operation.value.displayName + } + ); + break; + } + case "add": { + // TODO + break; + } + case "remove": { + // TODO + break; + } + default: { + throw new ScimRequestError({ + detail: "Invalid Operation", + status: 400 + }); + } + } + } + + if (!group) { + throw new ScimRequestError({ + detail: "Group Not Found", + status: 404 + }); + } + + return buildScimGroup({ + groupId: group.id, + name: group.name, + members: [] + }); + }; + + const deleteScimGroup = async ({ groupId, orgId }: TDeleteScimGroupDTO) => { + const [group] = await groupDAL.delete({ + id: groupId, + orgId + }); + + if (!group) { + throw new ScimRequestError({ + detail: "Group Not Found", + status: 404 + }); + } + + return {}; // intentionally return empty object upon success + }; + const fnValidateScimToken = async (token: TScimTokenJwtPayload) => { const scimToken = await scimDAL.findById(token.scimTokenId); if (!scimToken) throw new UnauthorizedError(); @@ -455,6 +682,13 @@ export const scimServiceFactory = ({ createScimUser, updateScimUser, replaceScimUser, + deleteScimUser, + listScimGroups, + createScimGroup, + getScimGroup, + deleteScimGroup, + updateScimGroupNamePut, + updateScimGroupNamePatch, fnValidateScimToken }; }; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index c99dec794..fc5df0b2e 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -59,6 +59,73 @@ export type TReplaceScimUserDTO = { orgId: string; }; +export type TDeleteScimUserDTO = { + userId: string; + orgId: string; +}; + +export type TListScimGroupsDTO = { + offset: number; + limit: number; + orgId: string; +}; + +export type TListScimGroups = { + schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]; + totalResults: number; + Resources: TScimGroup[]; + itemsPerPage: number; + startIndex: number; +}; + +export type TCreateScimGroupDTO = { + displayName: string; + orgId: string; +}; + +export type TGetScimGroupDTO = { + groupId: string; + orgId: string; +}; + +export type TUpdateScimGroupNamePutDTO = { + groupId: string; + orgId: string; + displayName: string; +}; + +export type TUpdateScimGroupNamePatchDTO = { + groupId: string; + orgId: string; + operations: (TRemoveOp | TReplaceOp | TAddOp)[]; +}; + +type TReplaceOp = { + op: "replace"; + value: { + id: string; + displayName: string; + }; +}; + +type TRemoveOp = { + op: "remove"; + path: string; +}; + +type TAddOp = { + op: "add"; + value: { + value: string; + display?: string; + }; +}; + +export type TDeleteScimGroupDTO = { + groupId: string; + orgId: string; +}; + export type TScimTokenJwtPayload = { scimTokenId: string; authTokenType: string; @@ -86,3 +153,17 @@ export type TScimUser = { location: null; }; }; + +export type TScimGroup = { + schemas: string[]; + id: string; + displayName: string; + members: { + value: string; + display: string; + }[]; + meta: { + resourceType: string; + location: null; + }; +}; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index b9325ed28..40772a207 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1,3 +1,34 @@ +export const GROUPS = { + CREATE: { + name: "The name of the group to create.", + slug: "The slug of the group to create.", + role: "The role of the group to create." + }, + UPDATE: { + currentSlug: "The current slug of the group to update.", + name: "The new name of the group to update to.", + slug: "The new slug of the group to update to.", + role: "The new role of the group to update to." + }, + DELETE: { + slug: "The slug of the group to delete" + }, + LIST_USERS: { + slug: "The slug of the group to list users for", + offset: "The offset to start from. If you enter 10, it will start from the 10th user.", + limit: "The number of users to return.", + username: "The username to search for." + }, + ADD_USER: { + slug: "The slug of the group to add the user to.", + username: "The username of the user to add to the group." + }, + DELETE_USER: { + slug: "The slug of the group to remove the user from.", + username: "The username of the user to remove from the group." + } +} as const; + export const IDENTITIES = { CREATE: { name: "The name of the identity to create.", @@ -79,6 +110,9 @@ export const ORGANIZATIONS = { }, GET_PROJECTS: { organizationId: "The ID of the organization to get projects from." + }, + LIST_GROUPS: { + organizationId: "The ID of the organization to list groups for." } } as const; @@ -142,6 +176,23 @@ export const PROJECTS = { ROLLBACK_TO_SNAPSHOT: { secretSnapshotId: "The ID of the snapshot to rollback to." }, + ADD_GROUP_TO_PROJECT: { + projectSlug: "The slug of the project to add the group to.", + groupSlug: "The slug of the group to add to the project.", + role: "The role for the group to assume in the project." + }, + UPDATE_GROUP_IN_PROJECT: { + projectSlug: "The slug of the project to update the group in.", + groupSlug: "The slug of the group to update in the project.", + roles: "A list of roles to update the group to." + }, + REMOVE_GROUP_FROM_PROJECT: { + projectSlug: "The slug of the project to delete the group from.", + groupSlug: "The slug of the group to delete from the project." + }, + LIST_GROUPS_IN_PROJECT: { + projectSlug: "The slug of the project to list groups for." + }, LIST_INTEGRATION: { workspaceId: "The ID of the project to list integrations for." }, diff --git a/backend/src/lib/types/index.ts b/backend/src/lib/types/index.ts index 7d26a5e5d..b3b46e739 100644 --- a/backend/src/lib/types/index.ts +++ b/backend/src/lib/types/index.ts @@ -1,5 +1,17 @@ import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +export type TGenericPermission = { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; +}; + +/** + * TODO(dangtony98): ideally move service fns to use TGenericPermission + * because TOrgPermission [orgId] is not as relevant anymore with the + * introduction of organizationIds bound to all user tokens + */ export type TOrgPermission = { actor: ActorType; actorId: string; @@ -16,6 +28,15 @@ export type TProjectPermission = { actorOrgId: string; }; +// same as TProjectPermission but with projectSlug requirement instead of projectId +export type TProjectSlugPermission = { + actor: ActorType; + actorId: string; + projectSlug: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; +}; + export type RequiredKeys = { [K in keyof T]-?: undefined extends T[K] ? never : K; }[keyof T]; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index be0494f0e..5d77c340b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -11,6 +11,9 @@ import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/pro import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal"; import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue"; import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; +import { groupDALFactory } from "@app/ee/services/group/group-dal"; +import { groupServiceFactory } from "@app/ee/services/group/group-service"; +import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal"; import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { ldapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal"; @@ -58,6 +61,9 @@ import { authPaswordServiceFactory } from "@app/services/auth/auth-password-serv import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; +import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; +import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { identityDALFactory } from "@app/services/identity/identity-dal"; import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal"; import { identityServiceFactory } from "@app/services/identity/identity-service"; @@ -207,6 +213,10 @@ export const registerRoutes = async ( const gitAppInstallSessionDAL = gitAppInstallSessionDALFactory(db); const gitAppOrgDAL = gitAppDALFactory(db); + const groupDAL = groupDALFactory(db); + const groupProjectDAL = groupProjectDALFactory(db); + const groupProjectMembershipRoleDAL = groupProjectMembershipRoleDALFactory(db); + const userGroupMembershipDAL = userGroupMembershipDALFactory(db); const secretScanningDAL = secretScanningDALFactory(db); const licenseDAL = licenseDALFactory(db); const dynamicSecretDAL = dynamicSecretDALFactory(db); @@ -249,6 +259,29 @@ export const registerRoutes = async ( samlConfigDAL, licenseService }); + const groupService = groupServiceFactory({ + userDAL, + groupDAL, + groupProjectDAL, + orgDAL, + userGroupMembershipDAL, + projectDAL, + projectBotDAL, + projectKeyDAL, + permissionService, + licenseService + }); + const groupProjectService = groupProjectServiceFactory({ + groupDAL, + groupProjectDAL, + groupProjectMembershipRoleDAL, + userGroupMembershipDAL, + projectDAL, + projectKeyDAL, + projectBotDAL, + projectRoleDAL, + permissionService + }); const scimService = scimServiceFactory({ licenseService, scimDAL, @@ -256,6 +289,7 @@ export const registerRoutes = async ( orgDAL, projectDAL, projectMembershipDAL, + groupDAL, permissionService, smtpService }); @@ -302,6 +336,7 @@ export const registerRoutes = async ( projectKeyDAL, smtpService, userDAL, + groupDAL, orgBotDAL }); const signupService = authSignupServiceFactory({ @@ -347,6 +382,7 @@ export const registerRoutes = async ( projectBotDAL, orgDAL, userDAL, + userGroupMembershipDAL, smtpService, projectKeyDAL, projectRoleDAL, @@ -626,6 +662,8 @@ export const registerRoutes = async ( password: passwordService, signup: signupService, user: userService, + group: groupService, + groupProject: groupProjectService, permission: permissionService, org: orgService, orgRole: orgRoleService, diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 651b76bac..808f125bb 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -1,6 +1,14 @@ import { z } from "zod"; -import { IncidentContactsSchema, OrganizationsSchema, OrgMembershipsSchema, UsersSchema } from "@app/db/schemas"; +import { + GroupsSchema, + IncidentContactsSchema, + OrganizationsSchema, + OrgMembershipsSchema, + OrgRolesSchema, + UsersSchema +} from "@app/db/schemas"; +import { ORGANIZATIONS } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -218,4 +226,41 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { return { incidentContactsOrg }; } }); + + server.route({ + method: "GET", + url: "/:organizationId/groups", + schema: { + params: z.object({ + organizationId: z.string().trim().describe(ORGANIZATIONS.LIST_GROUPS.organizationId) + }), + response: { + 200: z.object({ + groups: GroupsSchema.merge( + z.object({ + customRole: OrgRolesSchema.pick({ + id: true, + name: true, + slug: true, + permissions: true, + description: true + }).optional() + }) + ).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const groups = await server.services.org.getOrgGroups({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.params.organizationId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { groups }; + } + }); }; diff --git a/backend/src/server/routes/v2/group-project-router.ts b/backend/src/server/routes/v2/group-project-router.ts new file mode 100644 index 000000000..6d438c1ff --- /dev/null +++ b/backend/src/server/routes/v2/group-project-router.ts @@ -0,0 +1,201 @@ +import ms from "ms"; +import { z } from "zod"; + +import { + GroupProjectMembershipsSchema, + GroupsSchema, + ProjectMembershipRole, + ProjectUserMembershipRolesSchema +} from "@app/db/schemas"; +import { PROJECTS } from "@app/lib/api-docs"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types"; + +export const registerGroupProjectRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectSlug/groups/:groupSlug", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Add group to project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.projectSlug), + groupSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.groupSlug) + }), + body: z.object({ + role: z + .string() + .trim() + .min(1) + .default(ProjectMembershipRole.NoAccess) + .describe(PROJECTS.ADD_GROUP_TO_PROJECT.role) + }), + response: { + 200: z.object({ + groupMembership: GroupProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const groupMembership = await server.services.groupProject.addGroupToProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + groupSlug: req.params.groupSlug, + projectSlug: req.params.projectSlug, + role: req.body.role + }); + return { groupMembership }; + } + }); + + server.route({ + method: "PATCH", + url: "/:projectSlug/groups/:groupSlug", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update group in project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.projectSlug), + groupSlug: z.string().trim().describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.groupSlug) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .min(1) + .describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.roles) + }), + response: { + 200: z.object({ + roles: ProjectUserMembershipRolesSchema.array() + }) + } + }, + handler: async (req) => { + const roles = await server.services.groupProject.updateGroupInProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + groupSlug: req.params.groupSlug, + projectSlug: req.params.projectSlug, + roles: req.body.roles + }); + return { roles }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectSlug/groups/:groupSlug", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Remove group from project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECTS.REMOVE_GROUP_FROM_PROJECT.projectSlug), + groupSlug: z.string().trim().describe(PROJECTS.REMOVE_GROUP_FROM_PROJECT.groupSlug) + }), + response: { + 200: z.object({ + groupMembership: GroupProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const groupMembership = await server.services.groupProject.removeGroupFromProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + groupSlug: req.params.groupSlug, + projectSlug: req.params.projectSlug + }); + return { groupMembership }; + } + }); + + server.route({ + method: "GET", + url: "/:projectSlug/groups", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Return list of groups in project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECTS.LIST_GROUPS_IN_PROJECT.projectSlug) + }), + response: { + 200: z.object({ + groupMemberships: z + .object({ + id: z.string(), + groupId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + group: GroupsSchema.pick({ name: true, id: true, slug: true }) + }) + .array() + }) + } + }, + handler: async (req) => { + const groupMemberships = await server.services.groupProject.listGroupsInProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectSlug: req.params.projectSlug + }); + return { groupMemberships }; + } + }); +}; diff --git a/backend/src/server/routes/v2/identity-org-router.ts b/backend/src/server/routes/v2/identity-org-router.ts index 671cca0a1..b53e43ed0 100644 --- a/backend/src/server/routes/v2/identity-org-router.ts +++ b/backend/src/server/routes/v2/identity-org-router.ts @@ -50,6 +50,7 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, orgId: req.params.orgId }); + return { identityMemberships }; } }); diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index deebbc981..3d7581a70 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,3 +1,4 @@ +import { registerGroupProjectRouter } from "./group-project-router"; import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerIdentityProjectRouter } from "./identity-project-router"; import { registerMfaRouter } from "./mfa-router"; @@ -22,6 +23,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { async (projectServer) => { await projectServer.register(registerProjectRouter); await projectServer.register(registerIdentityProjectRouter); + await projectServer.register(registerGroupProjectRouter); await projectServer.register(registerProjectMembershipRouter); }, { prefix: "/workspace" } diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts new file mode 100644 index 000000000..3b0523dde --- /dev/null +++ b/backend/src/services/group-project/group-project-dal.ts @@ -0,0 +1,99 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, sqlNestRelationships } from "@app/lib/knex"; + +export type TGroupProjectDALFactory = ReturnType; + +export const groupProjectDALFactory = (db: TDbClient) => { + const groupProjectOrm = ormify(db, TableName.GroupProjectMembership); + + const findByProjectId = async (projectId: string, tx?: Knex) => { + try { + const docs = await (tx || db)(TableName.GroupProjectMembership) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .join(TableName.Groups, `${TableName.GroupProjectMembership}.groupId`, `${TableName.Groups}.id`) + .join( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.GroupProjectMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("createdAt").withSchema(TableName.GroupProjectMembership), + db.ref("updatedAt").withSchema(TableName.GroupProjectMembership), + db.ref("id").as("groupId").withSchema(TableName.Groups), + db.ref("name").as("groupName").withSchema(TableName.Groups), + db.ref("slug").as("groupSlug").withSchema(TableName.Groups), + db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("role").withSchema(TableName.GroupProjectMembershipRole), + db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("temporaryMode").withSchema(TableName.GroupProjectMembershipRole), + db.ref("isTemporary").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole) + ); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ groupId, groupName, groupSlug, id, createdAt, updatedAt }) => ({ + id, + groupId, + createdAt, + updatedAt, + group: { + id: groupId, + name: groupName, + slug: groupSlug + } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; + } catch (error) { + throw new DatabaseError({ error, name: "FindByProjectId" }); + } + }; + + return { ...groupProjectOrm, findByProjectId }; +}; diff --git a/backend/src/services/group-project/group-project-membership-role-dal.ts b/backend/src/services/group-project/group-project-membership-role-dal.ts new file mode 100644 index 000000000..5572ac6f5 --- /dev/null +++ b/backend/src/services/group-project/group-project-membership-role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TGroupProjectMembershipRoleDALFactory = ReturnType; + +export const groupProjectMembershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.GroupProjectMembershipRole); + return orm; +}; diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts new file mode 100644 index 000000000..589d0e474 --- /dev/null +++ b/backend/src/services/group-project/group-project-service.ts @@ -0,0 +1,338 @@ +import { ForbiddenError } from "@casl/ability"; +import ms from "ms"; + +import { ProjectMembershipRole, SecretKeyEncoding } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { isAtLeastAsPrivileged } from "@app/lib/casl"; +import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; + +import { TGroupDALFactory } from "../../ee/services/group/group-dal"; +import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal"; +import { TProjectDALFactory } from "../project/project-dal"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; +import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; +import { TGroupProjectDALFactory } from "./group-project-dal"; +import { TGroupProjectMembershipRoleDALFactory } from "./group-project-membership-role-dal"; +import { + TCreateProjectGroupDTO, + TDeleteProjectGroupDTO, + TListProjectGroupDTO, + TUpdateProjectGroupDTO +} from "./group-project-types"; + +type TGroupProjectServiceFactoryDep = { + groupProjectDAL: Pick; + groupProjectMembershipRoleDAL: Pick< + TGroupProjectMembershipRoleDALFactory, + "create" | "transaction" | "insertMany" | "delete" + >; + userGroupMembershipDAL: TUserGroupMembershipDALFactory; + projectDAL: Pick; + projectKeyDAL: Pick; + projectRoleDAL: Pick; + projectBotDAL: TProjectBotDALFactory; + groupDAL: Pick; + permissionService: Pick; +}; + +export type TGroupProjectServiceFactory = ReturnType; + +export const groupProjectServiceFactory = ({ + groupDAL, + groupProjectDAL, + groupProjectMembershipRoleDAL, + userGroupMembershipDAL, + projectDAL, + projectKeyDAL, + projectBotDAL, + projectRoleDAL, + permissionService +}: TGroupProjectServiceFactoryDep) => { + const addGroupToProject = async ({ + groupSlug, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectSlug, + role + }: TCreateProjectGroupDTO) => { + const project = await projectDAL.findOne({ + slug: projectSlug + }); + + if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` }); + if (project.version < 2) throw new BadRequestError({ message: `Failed to add group to E2EE project` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Groups); + + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const existingGroup = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id }); + if (existingGroup) + throw new BadRequestError({ + message: `Group with slug ${groupSlug} already exists in project with id ${project.id}` + }); + + const { permission: rolePermission, role: customRole } = await permissionService.getProjectPermissionByRole( + role, + project.id + ); + const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission); + if (!hasPrivilege) + throw new ForbiddenRequestError({ + message: "Failed to add group to project with more privileged role" + }); + const isCustomRole = Boolean(customRole); + + const projectGroup = await groupProjectDAL.transaction(async (tx) => { + const groupProjectMembership = await groupProjectDAL.create( + { + groupId: group.id, + projectId: project.id + }, + tx + ); + + await groupProjectMembershipRoleDAL.create( + { + projectMembershipId: groupProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : role, + customRoleId: customRole?.id + }, + tx + ); + return groupProjectMembership; + }); + + // share project key with users in group that have not + // individually been added to the project and that are not part of + // other groups that are in the project + const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id); + + if (groupMembers.length) { + const ghostUser = await projectDAL.findProjectGhostUser(project.id); + + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, project.id); + + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId: project.id }); + + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const plaintextProjectKey = decryptAsymmetric({ + ciphertext: ghostUserLatestKey.encryptedKey, + nonce: ghostUserLatestKey.nonce, + publicKey: ghostUserLatestKey.sender.publicKey, + privateKey: botPrivateKey + }); + + const projectKeyData = groupMembers.map(({ user: { publicKey, id } }) => { + const { ciphertext: encryptedKey, nonce } = encryptAsymmetric(plaintextProjectKey, publicKey, botPrivateKey); + + return { + encryptedKey, + nonce, + senderId: ghostUser.id, + receiverId: id, + projectId: project.id + }; + }); + + await projectKeyDAL.insertMany(projectKeyData); + } + + return projectGroup; + }; + + const updateGroupInProject = async ({ + projectSlug, + groupSlug, + roles, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateProjectGroupDTO) => { + const project = await projectDAL.findOne({ + slug: projectSlug + }); + + if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Groups); + + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const projectGroup = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id }); + if (!projectGroup) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + ); + const hasCustomRole = Boolean(customInputRoles.length); + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId: project.id, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const sanitizedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: projectGroup.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } + + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { + projectMembershipId: projectGroup.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const updatedRoles = await groupProjectMembershipRoleDAL.transaction(async (tx) => { + await groupProjectMembershipRoleDAL.delete({ projectMembershipId: projectGroup.id }, tx); + return groupProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); + }); + + return updatedRoles; + }; + + const removeGroupFromProject = async ({ + projectSlug, + groupSlug, + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TDeleteProjectGroupDTO) => { + const project = await projectDAL.findOne({ + slug: projectSlug + }); + + if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` }); + + const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug }); + if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const groupProjectMembership = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id }); + if (!groupProjectMembership) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Groups); + + const groupMembers = await userGroupMembershipDAL.findGroupMembersNotInProject(group.id, project.id); + + if (groupMembers.length) { + await projectKeyDAL.delete({ + projectId: project.id, + $in: { + receiverId: groupMembers.map(({ user: { id } }) => id) + } + }); + } + + const [deletedGroup] = await groupProjectDAL.delete({ groupId: group.id, projectId: project.id }); + + return deletedGroup; + }; + + const listGroupsInProject = async ({ + projectSlug, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListProjectGroupDTO) => { + const project = await projectDAL.findOne({ + slug: projectSlug + }); + + if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + + const groupMemberships = await groupProjectDAL.findByProjectId(project.id); + return groupMemberships; + }; + + return { + addGroupToProject, + updateGroupInProject, + removeGroupFromProject, + listGroupsInProject + }; +}; diff --git a/backend/src/services/group-project/group-project-types.ts b/backend/src/services/group-project/group-project-types.ts new file mode 100644 index 000000000..c867b75c0 --- /dev/null +++ b/backend/src/services/group-project/group-project-types.ts @@ -0,0 +1,31 @@ +import { TProjectSlugPermission } from "@app/lib/types"; + +import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types"; + +export type TCreateProjectGroupDTO = { + groupSlug: string; + role: string; +} & TProjectSlugPermission; + +export type TUpdateProjectGroupDTO = { + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; + groupSlug: string; +} & TProjectSlugPermission; + +export type TDeleteProjectGroupDTO = { + groupSlug: string; +} & TProjectSlugPermission; + +export type TListProjectGroupDTO = TProjectSlugPermission; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index d554c81ac..2da2492c0 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -163,7 +163,7 @@ export const identityProjectServiceFactory = ({ const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); - const santiziedProjectMembershipRoles = roles.map((inputRole) => { + const sanitizedProjectMembershipRoles = roles.map((inputRole) => { const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); if (!inputRole.isTemporary) { return { @@ -189,7 +189,7 @@ export const identityProjectServiceFactory = ({ const updatedRoles = await identityProjectMembershipRoleDAL.transaction(async (tx) => { await identityProjectMembershipRoleDAL.delete({ projectMembershipId: projectIdentity.id }, tx); - return identityProjectMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx); + return identityProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); }); return updatedRoles; @@ -246,8 +246,8 @@ export const identityProjectServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity); - const identityMemberhips = await identityProjectDAL.findByProjectId(projectId); - return identityMemberhips; + const identityMemberships = await identityProjectDAL.findByProjectId(projectId); + return identityMemberships; }; return { diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index cdea45631..2863bf23e 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -157,8 +157,8 @@ export const identityServiceFactory = ({ const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); - const identityMemberhips = await identityOrgMembershipDAL.findByOrgId(orgId); - return identityMemberhips; + const identityMemberships = await identityOrgMembershipDAL.findByOrgId(orgId); + return identityMemberships; }; return { diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 64cd51e09..c03fe1748 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -6,6 +6,7 @@ import { Knex } from "knex"; import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; import { TProjects } from "@app/db/schemas/projects"; +import { TGroupDALFactory } from "@app/ee/services/group/group-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -34,6 +35,7 @@ import { TDeleteOrgMembershipDTO, TFindAllWorkspacesDTO, TFindOrgMembersByEmailDTO, + TGetOrgGroupsDTO, TInviteUserToOrgDTO, TUpdateOrgDTO, TUpdateOrgMembershipDTO, @@ -45,6 +47,7 @@ type TOrgServiceFactoryDep = { orgBotDAL: TOrgBotDALFactory; orgRoleDAL: TOrgRoleDALFactory; userDAL: TUserDALFactory; + groupDAL: TGroupDALFactory; projectDAL: TProjectDALFactory; projectMembershipDAL: Pick; projectKeyDAL: Pick; @@ -64,6 +67,7 @@ export type TOrgServiceFactory = ReturnType; export const orgServiceFactory = ({ orgDAL, userDAL, + groupDAL, orgRoleDAL, incidentContactDAL, permissionService, @@ -113,6 +117,13 @@ export const orgServiceFactory = ({ return members; }; + const getOrgGroups = async ({ actor, actorId, orgId, actorAuthMethod, actorOrgId }: TGetOrgGroupsDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups); + const groups = await groupDAL.findByOrgId(orgId); + return groups; + }; + const findOrgMembersByUsername = async ({ actor, actorId, @@ -674,6 +685,7 @@ export const orgServiceFactory = ({ // incident contacts findIncidentContacts, createIncidentContact, - deleteIncidentContact + deleteIncidentContact, + getOrgGroups }; }; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index c811f9bc0..0efc7ffe1 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -53,3 +53,5 @@ export type TFindAllWorkspacesDTO = { export type TUpdateOrgDTO = { data: Partial<{ name: string; slug: string; authEnforced: boolean; scimEnabled: boolean }>; } & TOrgPermission; + +export type TGetOrgGroupsDTO = TOrgPermission; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 56fbe66f5..becde2b72 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -17,6 +17,7 @@ import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; +import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal"; import { ActorType } from "../auth/auth-type"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; @@ -45,6 +46,7 @@ type TProjectMembershipServiceFactoryDep = { projectMembershipDAL: TProjectMembershipDALFactory; projectUserMembershipRoleDAL: Pick; userDAL: Pick; + userGroupMembershipDAL: TUserGroupMembershipDALFactory; projectRoleDAL: Pick; orgDAL: Pick; projectDAL: Pick; @@ -63,6 +65,7 @@ export const projectMembershipServiceFactory = ({ projectBotDAL, orgDAL, userDAL, + userGroupMembershipDAL, projectDAL, projectKeyDAL, licenseService @@ -120,6 +123,13 @@ export const projectMembershipServiceFactory = ({ }); if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); + const userIdsToExcludeForProjectKeyAddition = new Set( + await userGroupMembershipDAL.findUserGroupMembershipsInProject( + orgMembers.map(({ username }) => username), + projectId + ) + ); + await projectMembershipDAL.transaction(async (tx) => { const projectMemberships = await projectMembershipDAL.insertMany( orgMembers.map(({ userId }) => ({ @@ -135,13 +145,15 @@ export const projectMembershipServiceFactory = ({ ); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); await projectKeyDAL.insertMany( - orgMembers.map(({ userId, id }) => ({ - encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, - nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, - senderId: actorId, - receiverId: userId as string, - projectId - })), + orgMembers + .filter(({ userId }) => !userIdsToExcludeForProjectKeyAddition.has(userId as string)) + .map(({ userId, id }) => ({ + encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, + nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, + senderId: actorId, + receiverId: userId as string, + projectId + })), tx ); }); @@ -247,6 +259,10 @@ export const projectMembershipServiceFactory = ({ const members: TProjectMemberships[] = []; + const userIdsToExcludeForProjectKeyAddition = new Set( + await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId) + ); + await projectMembershipDAL.transaction(async (tx) => { const projectMemberships = await projectMembershipDAL.insertMany( orgMembers.map(({ user }) => ({ @@ -265,13 +281,15 @@ export const projectMembershipServiceFactory = ({ const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); await projectKeyDAL.insertMany( - orgMembers.map(({ user, id }) => ({ - encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, - nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, - senderId: ghostUser.id, - receiverId: user.id, - projectId - })), + orgMembers + .filter(({ user }) => !userIdsToExcludeForProjectKeyAddition.has(user.id)) + .map(({ user, id }) => ({ + encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey, + nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: user.id, + projectId + })), tx ); }); @@ -344,7 +362,7 @@ export const projectMembershipServiceFactory = ({ if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); - const santiziedProjectMembershipRoles = roles.map((inputRole) => { + const sanitizedProjectMembershipRoles = roles.map((inputRole) => { const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); if (!inputRole.isTemporary) { return { @@ -370,7 +388,7 @@ export const projectMembershipServiceFactory = ({ const updatedRoles = await projectMembershipDAL.transaction(async (tx) => { await projectUserMembershipRoleDAL.delete({ projectMembershipId: membershipId }, tx); - return projectUserMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx); + return projectUserMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); }); return updatedRoles; @@ -458,6 +476,10 @@ export const projectMembershipServiceFactory = ({ }); } + const userIdsToExcludeFromProjectKeyRemoval = new Set( + await userGroupMembershipDAL.findUserGroupMembershipsInProject(usernamesAndEmails, projectId) + ); + const memberships = await projectMembershipDAL.transaction(async (tx) => { const deletedMemberships = await projectMembershipDAL.delete( { @@ -469,11 +491,15 @@ export const projectMembershipServiceFactory = ({ tx ); + // delete project keys belonging to users that are not part of any other groups in the project await projectKeyDAL.delete( { projectId, $in: { - receiverId: projectMembers.map(({ user }) => user.id).filter(Boolean) + receiverId: projectMembers + .filter(({ user }) => !userIdsToExcludeFromProjectKeyRemoval.has(user.id)) + .map(({ user }) => user.id) + .filter(Boolean) } }, tx diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index fae3615eb..42cc54393 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -30,8 +30,33 @@ export const projectDALFactory = (db: TDbClient) => { { column: `${TableName.Environment}.position`, order: "asc" } ]); + const groups: string[] = await db(TableName.UserGroupMembership) + .where({ userId }) + .select(selectAllTableCols(TableName.UserGroupMembership)) + .pluck("groupId"); + + const groupWorkspaces = await db(TableName.GroupProjectMembership) + .whereIn("groupId", groups) + .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) + .whereNotIn( + `${TableName.Project}.id`, + workspaces.map(({ id }) => id) + ) + .leftJoin(TableName.Environment, `${TableName.Environment}.projectId`, `${TableName.Project}.id`) + .select( + selectAllTableCols(TableName.Project), + db.ref("id").withSchema(TableName.Project).as("_id"), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("name").withSchema(TableName.Environment).as("envName") + ) + .orderBy([ + { column: `${TableName.Project}.name`, order: "asc" }, + { column: `${TableName.Environment}.position`, order: "asc" } + ]); + const nestedWorkspaces = sqlNestRelationships({ - data: workspaces, + data: workspaces.concat(groupWorkspaces), key: "id", parentMapper: ({ _id, ...el }) => ({ _id, ...ProjectsSchema.parse(el) }), childrenMapper: [ diff --git a/docs/documentation/platform/groups.mdx b/docs/documentation/platform/groups.mdx new file mode 100644 index 000000000..18bfe6fa5 --- /dev/null +++ b/docs/documentation/platform/groups.mdx @@ -0,0 +1,67 @@ +--- +title: "User Groups" +description: "Manage user groups in Infisical." +--- + + + User Groups is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, + then you should contact team@infisical.com to purchase an enterprise license to use it. + + +## 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. + +User 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. + +## Workflow + +In the following steps, we explore how to create and use user groups to provision user access to projects in Infisical. + + + + To create a group, head to your Organization Settings > Access Control > Groups and press **Create group**. + + ![groups org](/images/platform/groups/groups-org.png) + + When creating a group, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![groups org create](/images/platform/groups/groups-org-create.png) + + Now input a few details for your new group. Here’s some guidance for each field: + - Name (required): A friendly name for the group like `Engineering`. + - Slug (required): A unique identifier for the group like `engineering`. + - Role (required): A role from the Organization Roles tab for the group to assume. The organization role assigned will determine what organization level resources this group can have access to. + + + Next, you'll want to assign users to the group. To do this, press on the users icon on the group and start assigning users to the group. + + ![groups org users](/images/platform/groups/groups-org-users.png) + + In this example, we're assigning **Alan Turing** and **Ada Lovelace** to the group **Engineering**. + + ![groups org assign users](/images/platform/groups/groups-org-users-assign.png) + + + To enable the group to access project-level resources such as secrets within a specific project, you should add it to that project. + + To do this, head over to the project you want to add the group to and go to Project Settings > Access Control > Groups and press **Add group**. + + ![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. + + \ No newline at end of file diff --git a/docs/documentation/platform/scim/jumpcloud.mdx b/docs/documentation/platform/scim/jumpcloud.mdx index cf8d1aa88..ce4542035 100644 --- a/docs/documentation/platform/scim/jumpcloud.mdx +++ b/docs/documentation/platform/scim/jumpcloud.mdx @@ -16,7 +16,7 @@ Prerequisites: In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and - press the **Enable SCIM provisioning** toggle to allow JumpCloud to provision/deprovision users for your organization. + press the **Enable SCIM provisioning** toggle to allow JumpCloud to provision/deprovision users and user groups for your organization. ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) @@ -49,7 +49,7 @@ Prerequisites: ![SCIM JumpCloud](/images/platform/scim/jumpcloud/scim-jumpcloud-test-connection.png) - Now JumpCloud can provision/deprovision users to/from your organization in Infisical. + Now JumpCloud can provision/deprovision users and user groups to/from your organization in Infisical. diff --git a/docs/documentation/platform/scim/okta.mdx b/docs/documentation/platform/scim/okta.mdx index e5d5bb48b..6b0bf6ccf 100644 --- a/docs/documentation/platform/scim/okta.mdx +++ b/docs/documentation/platform/scim/okta.mdx @@ -16,7 +16,7 @@ Prerequisites: In Infisical, head to your Organization Settings > Authentication > SCIM Configuration and - press the **Enable SCIM provisioning** toggle to allow Okta to provision/deprovision users for your organization. + press the **Enable SCIM provisioning** toggle to allow Okta to provision/deprovision users and user groups for your organization. ![SCIM enable provisioning](/images/platform/scim/scim-enable-provisioning.png) @@ -38,7 +38,7 @@ Prerequisites: - SCIM connector base URL: Input the **SCIM URL** from Step 1. - Unique identifier field for users: Input `email`. - - Supported provisioning actions: Select **Push New Users** and **Push Profile Updates**. + - Supported provisioning actions: Select **Push New Users**, **Push Profile Updates**, and **Push Groups**. - Authentication Mode: `HTTP Header`. ![SCIM Okta](/images/platform/scim/okta/scim-okta-config.png) @@ -55,7 +55,7 @@ Prerequisites: ![SCIM Okta](/images/platform/scim/okta/scim-okta-app-settings.png) - Now Okta can provision/deprovision users to/from your organization in Infisical. + Now Okta can provision/deprovision users and user groups to/from your organization in Infisical. diff --git a/docs/documentation/platform/scim/overview.mdx b/docs/documentation/platform/scim/overview.mdx index 2bc73d810..232df95a1 100644 --- a/docs/documentation/platform/scim/overview.mdx +++ b/docs/documentation/platform/scim/overview.mdx @@ -10,7 +10,7 @@ description: "Learn how to provision users for Infisical via SCIM." then you should contact sales@infisical.com to purchase an enterprise license to use it. -You can configure your organization in Infisical to have members be provisioned/deprovisioned using [SCIM](https://scim.cloud/#Implementations2) via providers like Okta, Azure, JumpCloud, etc. +You can configure your organization in Infisical to have users and user groups be provisioned/deprovisioned using [SCIM](https://scim.cloud/#Implementations2) via providers like Okta, Azure, JumpCloud, etc. - Provisioning: The SCIM provider pushes user information to Infisical. If the user exists in Infisical, Infisical sends an email invitation to add them to the relevant organization in Infisical; if not, Infisical initializes a new user and sends them an email invitation to finish setting up their account in the organization. - Deprovisioning: The SCIM provider instructs Infisical to remove user(s) from an organization in Infisical. diff --git a/docs/images/platform/groups/groups-org-create.png b/docs/images/platform/groups/groups-org-create.png new file mode 100644 index 000000000..a8a1e677c Binary files /dev/null and b/docs/images/platform/groups/groups-org-create.png differ diff --git a/docs/images/platform/groups/groups-org-users-assign.png b/docs/images/platform/groups/groups-org-users-assign.png new file mode 100644 index 000000000..b5f629c2e Binary files /dev/null and b/docs/images/platform/groups/groups-org-users-assign.png differ diff --git a/docs/images/platform/groups/groups-org-users.png b/docs/images/platform/groups/groups-org-users.png new file mode 100644 index 000000000..383425e77 Binary files /dev/null and b/docs/images/platform/groups/groups-org-users.png differ diff --git a/docs/images/platform/groups/groups-org.png b/docs/images/platform/groups/groups-org.png new file mode 100644 index 000000000..13b2edc44 Binary files /dev/null and b/docs/images/platform/groups/groups-org.png differ diff --git a/docs/images/platform/groups/groups-project-create.png b/docs/images/platform/groups/groups-project-create.png new file mode 100644 index 000000000..9232aa042 Binary files /dev/null and b/docs/images/platform/groups/groups-project-create.png differ diff --git a/docs/images/platform/groups/groups-project.png b/docs/images/platform/groups/groups-project.png new file mode 100644 index 000000000..83e384861 Binary files /dev/null and b/docs/images/platform/groups/groups-project.png differ diff --git a/docs/images/platform/scim/okta/scim-okta-config.png b/docs/images/platform/scim/okta/scim-okta-config.png index b20ceddca..bca1a25eb 100644 Binary files a/docs/images/platform/scim/okta/scim-okta-config.png and b/docs/images/platform/scim/okta/scim-okta-config.png differ diff --git a/docs/mint.json b/docs/mint.json index 47c8561ab..8335c8bd6 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -144,7 +144,8 @@ "documentation/platform/dynamic-secrets/overview", "documentation/platform/dynamic-secrets/postgresql" ] - } + }, + "documentation/platform/groups" ] }, { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4eb7e76fd..fbebc0884 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "frontend", "dependencies": { "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 8127cb772..95a9d00ac 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -16,6 +16,7 @@ export enum OrgPermissionSubjects { Scim = "scim", Sso = "sso", Ldap = "ldap", + Groups = "groups", Billing = "billing", SecretScanning = "secret-scanning", Identity = "identity" @@ -31,6 +32,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] + | [OrgPermissionActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity]; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 608419f4f..79c8f2d30 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -10,6 +10,7 @@ export enum ProjectPermissionActions { export enum ProjectPermissionSub { Role = "role", Member = "member", + Groups = "groups", Settings = "settings", Integrations = "integrations", Webhooks = "webhooks", @@ -39,6 +40,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] | [ProjectPermissionActions, ProjectPermissionSub.Member] + | [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] diff --git a/frontend/src/hooks/api/groups/index.tsx b/frontend/src/hooks/api/groups/index.tsx new file mode 100644 index 000000000..26b38d3a4 --- /dev/null +++ b/frontend/src/hooks/api/groups/index.tsx @@ -0,0 +1,9 @@ +export { + useAddUserToGroup, + useCreateGroup, + useDeleteGroup, + useRemoveUserFromGroup, + useUpdateGroup} from "./mutations"; +export { + useListGroupUsers +} from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx new file mode 100644 index 000000000..32d718e37 --- /dev/null +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -0,0 +1,130 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { organizationKeys } from "../organization/queries"; +import { groupKeys } from "./queries"; +import { TGroup } from "./types"; + +export const useCreateGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + name, + slug, + role + }: { + name: string; + slug: string; + organizationId: string; + role?: string; + }) => { + const { + data: group + } = await apiRequest.post("/api/v1/groups", { + name, + slug, + role + }); + + return group; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgGroups(organizationId)); + } + }); +}; + +export const useUpdateGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + currentSlug, + name, + slug, + role + }: { + currentSlug: string; + name?: string; + slug?: string; + role?: string; + }) => { + const { + data: group + } = await apiRequest.patch(`/api/v1/groups/${currentSlug}`, { + name, + slug, + role + }); + + return group; + }, + onSuccess: ({ orgId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + } + }); +}; + +export const useDeleteGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + slug + }: { + slug: string; + }) => { + const { + data: group + } = await apiRequest.delete(`/api/v1/groups/${slug}`); + + return group; + }, + onSuccess: ({ orgId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgGroups(orgId)); + } + }); +}; + +export const useAddUserToGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + slug, + username + }: { + slug: string; + username: string; + }) => { + const { + data + } = await apiRequest.post(`/api/v1/groups/${slug}/users/${username}`); + + return data; + }, + onSuccess: (_, { slug }) => { + queryClient.invalidateQueries(groupKeys.forGroupUserMemberships(slug)); + } + }); +}; + +export const useRemoveUserFromGroup = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + slug, + username + }: { + slug: string; + username: string; + }) => { + const { + data + } = await apiRequest.delete(`/api/v1/groups/${slug}/users/${username}`); + + return data; + }, + onSuccess: (_, { slug }) => { + queryClient.invalidateQueries(groupKeys.forGroupUserMemberships(slug)); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx new file mode 100644 index 000000000..ba0543135 --- /dev/null +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -0,0 +1,65 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +export const groupKeys = { + allGroupUserMemberships: () => ["group-user-memberships"] as const, + forGroupUserMemberships: (slug: string) => [...groupKeys.allGroupUserMemberships(), slug] as const, + specificGroupUserMemberships: ({ + slug, + offset, + limit, + username + }: { + slug: string; + offset: number; + limit: number; + username: string; + }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, username }] as const +}; + +type TUser = { + id: string; + email: string; + username: string; + firstName: string; + lastName: string; + isPartOfGroup: boolean; +}; + +export const useListGroupUsers = ({ + groupSlug, + offset = 0, + limit = 10, + username +}: { + groupSlug: string; + offset: number; + limit: number; + username: string; +}) => { + return useQuery({ + queryKey: groupKeys.specificGroupUserMemberships({ + slug: groupSlug, + offset, + limit, + username + }), + enabled: Boolean(groupSlug), + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + username + }); + + const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number; }>( + `/api/v1/groups/${groupSlug}/users`, { + params + } + ); + + return data; + }, + }); +}; diff --git a/frontend/src/hooks/api/groups/types.ts b/frontend/src/hooks/api/groups/types.ts new file mode 100644 index 000000000..3db2357cd --- /dev/null +++ b/frontend/src/hooks/api/groups/types.ts @@ -0,0 +1,36 @@ +import { TOrgRole } from "../roles/types"; + +// TODO: rectify/standardize types + +export type TGroupOrgMembership = TGroup & { + customRole?: TOrgRole; +} + +export type TGroup = { + id: string; + name: string; + slug: string; + orgId: string; + createAt: string; + updatedAt: string; + role: string; +}; + +export type TGroupMembership = { + id: string; + group: TGroup; + roles: { + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: boolean; + temporaryMode: string | null; + temporaryRange: string | null; + temporaryAccessStartTime: string | null; + temporaryAccessEndTime: string | null; + }[]; + createdAt: string; + updatedAt: string; +}; \ No newline at end of file diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index ed89b51ba..b2df27f2d 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -5,6 +5,7 @@ export * from "./auth"; export * from "./bots"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; +export * from "./groups"; export * from "./identities"; export * from "./identityProjectAdditionalPrivilege"; export * from "./incidentContacts"; diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index 9570f29b6..f7a898ef0 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -7,7 +7,8 @@ export { useDeleteOrgPmtMethod, useDeleteOrgTaxId, useGetIdentityMembershipOrgs, - useGetOrganizations, + useGetOrganizationGroups, + useGetOrganizations, useGetOrgBillingDetails, useGetOrgInvoices, useGetOrgLicenses, @@ -19,4 +20,4 @@ export { useGetOrgTrialUrl, useUpdateOrg, useUpdateOrgBillingDetails -} from "./queries"; +} from "./queries"; \ No newline at end of file diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 9e763c771..5c57428df 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { TGroupOrgMembership } from "../groups/types"; import { IdentityMembershipOrg } from "../identities/types"; import { BillingDetails, @@ -28,7 +29,8 @@ export const organizationKeys = { getOrgInvoices: (orgId: string) => [{ orgId }, "organization-invoices"] as const, getOrgLicenses: (orgId: string) => [{ orgId }, "organization-licenses"] as const, getOrgIdentityMemberships: (orgId: string) => - [{ orgId }, "organization-identity-memberships"] as const + [{ orgId }, "organization-identity-memberships"] as const, + getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const }; export const fetchOrganizations = async () => { @@ -404,3 +406,19 @@ export const useDeleteOrgById = () => { } }); }; + +export const useGetOrganizationGroups = (organizationId: string) => { + return useQuery({ + queryKey: organizationKeys.getOrgGroups(organizationId), + enabled: Boolean(organizationId), + queryFn: async () => { + const { + data: { groups } + } = await apiRequest.get<{ groups: TGroupOrgMembership[] }>( + `/api/v1/organization/${organizationId}/groups` + ); + + return groups; + } + }); +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 978de8578..46facedd3 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -21,6 +21,7 @@ export type SubscriptionPlan = { samlSSO: boolean; scim: boolean; ldap: boolean; + groups: boolean; status: | "incomplete" | "incomplete_expired" diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index b96223077..b0cadac23 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -1,3 +1,8 @@ +export { + useAddGroupToWorkspace, + useDeleteGroupFromWorkspace, + useUpdateGroupWorkspaceRole +} from "./mutations"; export { useAddIdentityToWorkspace, useCreateWorkspace, @@ -16,6 +21,7 @@ export { useGetWorkspaceIntegrations, useGetWorkspaceSecrets, useGetWorkspaceUsers, + useListWorkspaceGroups, useNameWorkspaceSecrets, useRenameWorkspace, useToggleAutoCapitalization, diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx new file mode 100644 index 000000000..11853157f --- /dev/null +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -0,0 +1,64 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "./queries"; +import { TUpdateWorkspaceGroupRoleDTO } from "./types"; + +export const useAddGroupToWorkspace = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + groupSlug, + projectSlug, + role + }: { + groupSlug: string; + projectSlug: string; + role?: string; + }) => { + const { + data: { groupMembership } + } = await apiRequest.post(`/api/v2/workspace/${projectSlug}/groups/${groupSlug}`, { + role + }); + return groupMembership; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(projectSlug)); + } + }); +}; + +export const useUpdateGroupWorkspaceRole = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ groupSlug, projectSlug, roles }: TUpdateWorkspaceGroupRoleDTO) => { + const { + data: { groupMembership } + } = await apiRequest.patch(`/api/v2/workspace/${projectSlug}/groups/${groupSlug}`, { + roles + }); + + return groupMembership; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(projectSlug)); + } + }); +}; + +export const useDeleteGroupFromWorkspace = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ groupSlug, projectSlug }: { groupSlug: string; projectSlug: string }) => { + const { + data: { groupMembership } + } = await apiRequest.delete(`/api/v2/workspace/${projectSlug}/groups/${groupSlug}`); + return groupMembership; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(projectSlug)); + } + }); +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 001454233..cca544df3 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { TGroupMembership } from "../groups/types"; import { IdentityMembership } from "../identities/types"; import { IntegrationAuth } from "../integrationAuth/types"; import { TIntegration } from "../integrations/types"; @@ -36,7 +37,9 @@ export const workspaceKeys = { [{ workspaceId }, "workspace-audit-logs"] as const, getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const, getWorkspaceIdentityMemberships: (workspaceId: string) => - [{ workspaceId }, "workspace-identity-memberships"] as const + [{ workspaceId }, "workspace-identity-memberships"] as const, + getWorkspaceGroupMemberships: (workspaceId: string) => + [{ workspaceId }, "workspace-groups"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -450,3 +453,18 @@ export const useGetWorkspaceIdentityMemberships = (workspaceId: string) => { enabled: true }); }; + +export const useListWorkspaceGroups = (projectSlug: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceGroupMemberships(projectSlug), + queryFn: async () => { + const { + data: { groupMemberships } + } = await apiRequest.get<{ groupMemberships: TGroupMembership[] }>( + `/api/v2/workspace/${projectSlug}/groups` + ); + return groupMemberships; + }, + enabled: true + }); +}; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index f0cee7741..8be9beed0 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -111,3 +111,21 @@ export type TUpdateWorkspaceIdentityRoleDTO = { } )[]; }; + +export type TUpdateWorkspaceGroupRoleDTO = { + groupSlug: string; + projectSlug: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; +}; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/MembersPage.tsx b/frontend/src/views/Org/MembersPage/MembersPage.tsx index ccc868c0f..99edd5015 100644 --- a/frontend/src/views/Org/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx @@ -3,10 +3,16 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { withPermission } from "@app/hoc"; -import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components"; +import { + OrgGroupsTab, + OrgIdentityTab, + OrgMembersTab, + OrgRoleTabSection +} from "./components"; enum TabSections { Member = "members", + Groups = "groups", Roles = "roles", Identities = "identities" } @@ -20,6 +26,7 @@ export const MembersPage = withPermission( People + Groups

Machine Identities

@@ -30,6 +37,9 @@ export const MembersPage = withPermission( + + + diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/OrgGroupsTab.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/OrgGroupsTab.tsx new file mode 100644 index 000000000..1194d8696 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/OrgGroupsTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { OrgGroupsSection } from "./components"; + +export const OrgGroupsTab = () => { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx new file mode 100644 index 000000000..8c15643b7 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx @@ -0,0 +1,169 @@ +import { useState } from "react"; +import { faMagnifyingGlass,faUsers } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +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 { + OrgPermissionActions, + OrgPermissionSubjects +} from "@app/context"; +import { + useAddUserToGroup, + useListGroupUsers, + useRemoveUserFromGroup} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["groupMembers"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["groupMembers"]>, state?: boolean) => void; +}; + +export const OrgGroupMembersModal = ({ + popUp, + handlePopUpToggle +}: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const [searchMemberFilter, setSearchMemberFilter] = useState(""); + + const popUpData = popUp?.groupMembers?.data as { + slug: string; + }; + + const { data, isLoading } = useListGroupUsers({ + groupSlug: popUpData?.slug, + offset: (page - 1) * perPage, + limit: perPage, + username: searchMemberFilter + }); + + const { mutateAsync: assignMutateAsync } = useAddUserToGroup(); + const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup(); + + const handleAssignment = async (username: string, assign: boolean) => { + try { + if (!popUpData?.slug) return; + + if (assign) { + await assignMutateAsync({ + username, + slug: popUpData.slug + }); + } else { + await unassignMutateAsync({ + username, + slug: popUpData.slug + }); + } + + createNotification({ + text: `Successfully ${assign ? "assigned" : "removed "} user ${assign ? "to" : "from"} group`, + type: "success" + }); + } catch (err) { + createNotification({ + text: `Failed to ${assign ? "assigned" : "remove"} user ${assign ? "to" : "from"} group`, + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("groupMembers", isOpen); + }} + > + + setSearchMemberFilter(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> + + + + + + + + + {isLoading && } + {!isLoading && data?.users?.map(({ + id, + firstName, + lastName, + username, + isPartOfGroup + }) => { + return ( + + + + + ); + })} + +
User +
+

{`${firstName} ${lastName}`}

+

{username}

+
+ + {(isAllowed) => { + return ( + + ); + }} + +
+ {!isLoading && data?.totalCount !== undefined && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isLoading && !data?.users?.length && ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx new file mode 100644 index 000000000..7791eaa06 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupModal.tsx @@ -0,0 +1,188 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const GroupFormSchema = z.object({ + name: z.string().min(1, "Name cannot be empty").max(50, "Name must be 50 characters or fewer"), + slug: z.string().min(5, "Slug cannot be empty").max(36, "Slug must be 36 characters or fewer"), + role: z.string() +}); + +export type TGroupFormData = z.infer; + +type Props = { + popUp: UsePopUpState<["group"]>; + handlePopUpClose: (popUpName: keyof UsePopUpState<["group"]>) => void; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void; +}; + +export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { data: roles } = useGetOrgRoles(currentOrg?.id || ""); + const { mutateAsync: createMutateAsync, isLoading: createIsLoading } = useCreateGroup(); + const { mutateAsync: updateMutateAsync, isLoading: updateIsLoading } = useUpdateGroup(); + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(GroupFormSchema) + }); + + useEffect(() => { + const group = popUp?.group?.data as { + groupId: string; + name: string; + slug: string; + role: string; + customRole: { + name: string; + slug: string; + }; + }; + + if (!roles?.length) return; + + if (group) { + reset({ + name: group.name, + slug: group.slug, + role: group?.customRole?.slug ?? group.role + }); + } else { + reset({ + name: "", + slug: "", + role: roles[0].slug + }); + } + }, [popUp?.group?.data, roles]); + + const onGroupModalSubmit = async ({ name, slug, role }: TGroupFormData) => { + try { + if (!currentOrg?.id) return; + + const group = popUp?.group?.data as { + groupId: string; + name: string; + slug: string; + }; + + if (group) { + await updateMutateAsync({ + currentSlug: group.slug, + name, + slug, + role: role || undefined + }); + } else { + await createMutateAsync({ + name, + slug, + organizationId: currentOrg.id, + role: role || undefined + }); + } + handlePopUpToggle("group", false); + reset(); + + createNotification({ + text: `Successfully ${popUp?.group?.data ? "updated" : "created"} group`, + type: "success" + }); + } catch (err) { + createNotification({ + text: `Failed to ${popUp?.group?.data ? "updated" : "created"} group`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("group", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx new file mode 100644 index 000000000..320c115fd --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx @@ -0,0 +1,120 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + UpgradePlanModal +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useSubscription +} from "@app/context"; +import { useDeleteGroup } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { OrgGroupMembersModal } from "./OrgGroupMembersModal"; +import { OrgGroupModal } from "./OrgGroupModal"; +import { OrgGroupsTable } from "./OrgGroupsTable"; + +export const OrgGroupsSection = () => { + const { subscription } = useSubscription(); + const { mutateAsync: deleteMutateAsync } = useDeleteGroup(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "group", + "groupMembers", + "deleteGroup", + "upgradePlan" + ] as const); + + const handleAddGroupModal = () => { + if (!subscription?.groups) { + handlePopUpOpen("upgradePlan", { + description: "You can manage users more efficiently with groups if you upgrade your Infisical plan." + }); + } else { + handlePopUpOpen("group"); + } + } + + const onDeleteGroupSubmit = async ({ + name, + slug + }: { + name: string; + slug: string; + }) => { + try { + await deleteMutateAsync({ + slug + }); + createNotification({ + text: `Successfully deleted the group named ${name}`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to delete the group named ${name}`, + type: "error" + }); + } + + handlePopUpClose("deleteGroup"); + } + + return ( +
+
+

Groups

+ + {(isAllowed) => ( + + )} + +
+ + + + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onDeleteGroupSubmit( + (popUp?.deleteGroup?.data as { name: string; slug: string }) + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx new file mode 100644 index 000000000..79e08bc14 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx @@ -0,0 +1,234 @@ +import { useState } from "react"; +import { faMagnifyingGlass, faPencil, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Input, + Select, + SelectItem, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization} from "@app/context"; +import { + useGetOrganizationGroups, + useGetOrgRoles, + useUpdateGroup +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["group", "deleteGroup", "groupMembers"] + >, + data?: { + groupId?: string; + name?: string; + slug?: string; + role?: string; + customRole?: { + name: string; + slug: string; + } + } + ) => void; + }; + +export const OrgGroupsTable = ({ + handlePopUpOpen +}: Props) => { + const [searchGroupsFilter, setSearchGroupsFilter] = useState(""); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + const { isLoading, data: groups } = useGetOrganizationGroups(orgId); + const { mutateAsync: updateMutateAsync } = useUpdateGroup(); + + const { data: roles } = useGetOrgRoles(orgId); + + const handleChangeRole = async ({ + currentSlug, + role + }: { + currentSlug: string; + role: string; + }) => { + try { + await updateMutateAsync({ + currentSlug, + role + }); + + createNotification({ + text: "Successfully updated group role", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update group role", + type: "error" + }); + } + } + + return ( +
+ setSearchGroupsFilter(e.target.value)} + leftIcon={} + placeholder="Search groups..." + /> + + + + + + + + + + + {isLoading && } + {!isLoading && groups?.map(({ id, name, slug, role, customRole }) => { + return ( + + + + + + + ); + })} + +
NameSlugRole +
{name}{slug} + + {(isAllowed) => { + return ( + + ); + }} + + +
+ + {(isAllowed) => ( + + { + handlePopUpOpen("groupMembers", { + slug + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + isDisabled={!isAllowed} + > + + + + )} + + + {(isAllowed) => ( + + { + handlePopUpOpen("group", { + groupId: id, + name, + slug, + role, + customRole + }); + }} + size="lg" + colorSchema="primary" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + + )} + + + {(isAllowed) => ( + + { + handlePopUpOpen("deleteGroup", { + slug, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + + )} + +
+
+ {groups?.length === 0 && ( + + )} +
+
+ ); +} diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/index.tsx new file mode 100644 index 000000000..ca3caabea --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/index.tsx @@ -0,0 +1 @@ +export { OrgGroupsSection } from "./OrgGroupsSection"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/index.tsx new file mode 100644 index 000000000..ca3caabea --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/index.tsx @@ -0,0 +1 @@ +export { OrgGroupsSection } from "./OrgGroupsSection"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/index.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/index.tsx new file mode 100644 index 000000000..69936a978 --- /dev/null +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/index.tsx @@ -0,0 +1 @@ +export { OrgGroupsTab } from "./OrgGroupsTab"; \ No newline at end of file diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx index 303878606..4ab44e947 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx @@ -151,7 +151,7 @@ export const IdentityModal = ({ popUp, /* handlePopUpOpen, */ handlePopUpToggle const error = err as any; const text = error?.response?.data?.message ?? - `Failed to ${popUp?.identity?.data ? "updated" : "created"} identity`; + `Failed to ${popUp?.identity?.data ? "update" : "create"} identity`; createNotification({ text, diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/OrgMembersTab.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/OrgMembersTab.tsx index 4b04b0128..5d50431b7 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/OrgMembersTab.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/OrgMembersTab.tsx @@ -5,13 +5,13 @@ import { OrgMembersSection } from "./components"; export const OrgMembersTab = () => { return ( - + ); -}; +} diff --git a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index e71ba14b8..02afdefbf 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -264,7 +264,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLink }: Prop {!isLoading && filterdUser?.length === 0 && ( - + )}
diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx index 773b99cfc..32bbe4786 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.tsx @@ -7,6 +7,7 @@ import { faMoneyBill, faServer, faSignIn, + faUser, faUserCog, faUsers } from "@fortawesome/free-solid-svg-icons"; @@ -35,11 +36,17 @@ type Props = { const SIMPLE_PERMISSION_OPTIONS = [ { - title: "Members management", - subtitle: "Invite, view and remove members from the organization", - icon: faUsers, + title: "User management", + subtitle: "Invite, view and remove users from the organization", + icon: faUser, formName: "member" }, + { + title: "Group management", + subtitle: "Invite, view and remove user groups from the organization", + icon: faUsers, + formName: "groups" + }, { title: "Machine identity management", subtitle: "Create, view, update and remove (machine) identities from the organization", diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts index f5e535a18..e85e62d0c 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils.ts @@ -28,6 +28,7 @@ export const formSchema = z.object({ }) .optional(), member: generalPermissionSchema, + groups: generalPermissionSchema, role: generalPermissionSchema, settings: generalPermissionSchema, "service-account": generalPermissionSchema, diff --git a/frontend/src/views/Org/MembersPage/components/index.tsx b/frontend/src/views/Org/MembersPage/components/index.tsx index 6a83bde02..f6e98cb5c 100644 --- a/frontend/src/views/Org/MembersPage/components/index.tsx +++ b/frontend/src/views/Org/MembersPage/components/index.tsx @@ -1,3 +1,4 @@ +export { OrgGroupsTab } from "./OrgGroupsTab"; export { OrgIdentityTab } from "./OrgIdentityTab"; export { OrgMembersTab } from "./OrgMembersTab"; export { OrgRoleTabSection } from "./OrgRoleTabSection"; diff --git a/frontend/src/views/Project/MembersPage/MembersPage.tsx b/frontend/src/views/Project/MembersPage/MembersPage.tsx index 66c01f773..edd03fade 100644 --- a/frontend/src/views/Project/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Project/MembersPage/MembersPage.tsx @@ -2,20 +2,28 @@ import { motion } from "framer-motion"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { withProjectPermission } from "@app/hoc"; -import { IdentityTab, MemberListTab, ProjectRoleListTab, ServiceTokenTab } from "./components"; +import { + GroupsTab, + IdentityTab, + MemberListTab, + ProjectRoleListTab, + ServiceTokenTab +} from "./components"; enum TabSections { Member = "members", Roles = "roles", + Groups = "groups", Identities = "identities", ServiceTokens = "service-tokens" } export const MembersPage = withProjectPermission( () => { + const { currentWorkspace } = useWorkspace(); return (
@@ -23,6 +31,9 @@ export const MembersPage = withProjectPermission( People + {currentWorkspace?.version && currentWorkspace.version > 1 && ( + Groups + )}

Machine Identities

@@ -34,6 +45,19 @@ export const MembersPage = withProjectPermission( + {currentWorkspace?.version && currentWorkspace.version > 1 && ( + + + + + + )} diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/GroupsTab.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/GroupsTab.tsx new file mode 100644 index 000000000..766cc77cb --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/GroupsTab.tsx @@ -0,0 +1,19 @@ +import { motion } from "framer-motion"; + +import { + GroupsSection +} from "./components"; + +export const GroupsTab = () => { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx new file mode 100644 index 000000000..818c6eae0 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx @@ -0,0 +1,182 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import Link from "next/link"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Modal, + ModalContent, + Select, + SelectItem} from "@app/components/v2"; +import { useOrganization, useWorkspace } from "@app/context"; +import { + useAddGroupToWorkspace, + useGetOrganizationGroups, + useGetProjectRoles, + useListWorkspaceGroups, +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + slug: z.string(), + role: z.string() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["group"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void; +}; + +export const GroupModal = ({ + popUp, + handlePopUpToggle +}: Props) => { + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); + + const orgId = currentOrg?.id || ""; + const workspaceId = currentWorkspace?.id || ""; + + const { data: groups } = useGetOrganizationGroups(orgId); + const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.slug || ""); + + const { data: roles } = useGetProjectRoles(workspaceId); + + const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace(); + + const filteredGroupMembershipOrgs = useMemo(() => { + const wsGroupIds = new Map(); + + groupMemberships?.forEach((groupMembership) => { + wsGroupIds.set(groupMembership.group.id, true); + }); + + return (groups || []).filter(({ id }) => !wsGroupIds.has(id)); + }, [groups, groupMemberships]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ slug, role }: FormData) => { + try { + await addGroupToWorkspaceMutateAsync({ + projectSlug: currentWorkspace?.slug || "", + groupSlug: slug, + role: role || undefined + }); + + reset(); + handlePopUpToggle("group", false); + + createNotification({ + text: "Successfully added group to project", + type: "success" + }); + + } catch (err) { + createNotification({ + text: "Failed to add group to project", + type: "error" + }); + } + } + + return ( + { + handlePopUpToggle("group", isOpen); + reset(); + }} + > + + {filteredGroupMembershipOrgs.length ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ) : ( +
+
+ All groups in your organization have already been added to this project. +
+ + + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx new file mode 100644 index 000000000..83ea00ede --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx @@ -0,0 +1,457 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + FormControl, + HoverCard, + HoverCardContent, + HoverCardTrigger, + IconButton, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Tag, + Tooltip +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetProjectRoles, useUpdateGroupWorkspaceRole } from "@app/hooks/api"; +import { TGroupMembership } from "@app/hooks/api/groups/types"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types"; +import { groupBy } from "@app/lib/fn/array"; + +const temporaryRoleFormSchema = z.object({ + temporaryRange: z.string().min(1, "Required") +}); + +type TTemporaryRoleFormSchema = z.infer; + +type TTemporaryRoleFormProps = { + temporaryConfig?: { + isTemporary?: boolean; + temporaryAccessEndTime?: string | null; + temporaryAccessStartTime?: string | null; + temporaryRange?: string | null; + }; + onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void; + onRemoveTemporary: () => void; +}; + +const IdentityTemporaryRoleForm = ({ + temporaryConfig: defaultValues = {}, + onSetTemporary, + onRemoveTemporary +}: TTemporaryRoleFormProps) => { + const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const); + const { control, handleSubmit } = useForm({ + resolver: zodResolver(temporaryRoleFormSchema), + values: { + temporaryRange: defaultValues.temporaryRange || "1h" + } + }); + const isTemporaryFieldValue = defaultValues.isTemporary; + const isExpired = + isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || ""); + + return ( + { + handlePopUpToggle("setTempRole", isOpen); + }} + > + + + + + + + + +
+
+ Set Role Temporarily +
+ {isExpired && Expired} + ( + + 1m, 2h, 3d.{" "} + + More + + + } + > + + + )} + /> +
+ {isTemporaryFieldValue && ( + + )} + {!isTemporaryFieldValue ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +const formSchema = z.record( + z.object({ + isChecked: z.boolean().optional(), + temporaryAccess: z.union([ + z.object({ + isTemporary: z.literal(true), + temporaryRange: z.string().min(1), + temporaryAccessStartTime: z.string().datetime(), + temporaryAccessEndTime: z.string().datetime().nullable().optional() + }), + z.boolean() + ]) + }) +); +type TForm = z.infer; + +export type TMemberRolesProp = { + disableEdit?: boolean; + groupSlug: string; + roles: TGroupMembership["roles"]; +}; + +const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; + +export const GroupRoles = ({ + roles = [], + disableEdit = false, + groupSlug +}: TMemberRolesProp) => { + const { currentWorkspace } = useWorkspace(); + const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); + const [searchRoles, setSearchRoles] = useState(""); + + const { + handleSubmit, + control, + reset, + setValue, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema) + }); + + const workspaceId = currentWorkspace?.id || ""; + + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); + + const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole(); + + const handleRoleUpdate = async (data: TForm) => { + const selectedRoles = Object.keys(data) + .filter((el) => Boolean(data[el].isChecked)) + .map((el) => { + const isTemporary = Boolean(data[el].temporaryAccess); + if (!isTemporary) { + return { role: el, isTemporary: false as const }; + } + + const tempCfg = data[el].temporaryAccess as { + temporaryRange: string; + temporaryAccessStartTime: string; + }; + + return { + role: el, + isTemporary: true as const, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: tempCfg.temporaryRange, + temporaryAccessStartTime: tempCfg.temporaryAccessStartTime + }; + }); + + try { + await updateGroupWorkspaceRole.mutateAsync({ + projectSlug: currentWorkspace?.slug || "", + groupSlug, + roles: selectedRoles + }); + createNotification({ text: "Successfully updated group role", type: "success" }); + handlePopUpToggle("editRole"); + setSearchRoles(""); + } catch (err) { + createNotification({ text: "Failed to update group role", type: "error" }); + } + }; + + const formatRoleName = (role: string, customRoleName?: string) => { + if (role === ProjectMembershipRole.Custom) return customRoleName; + if (role === ProjectMembershipRole.Member) return "Developer"; + return role; + }; + + return ( +
+ {roles + .slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + + +
+ )} +
+
+ ); + })} + {roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && ( + + + +{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE} + + + {roles + .slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE) + .map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => { + const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string)); + return ( + +
+
{formatRoleName(role, customRoleName)}
+ {isTemporary && ( +
+ + new Date(temporaryAccessEndTime as string) && + "text-red-600" + )} + /> + +
+ )} +
+
+ ); + })}{" "} +
+
+ )} +
+ { + handlePopUpToggle("editRole", isOpen); + reset(); + }} + > + {!disableEdit && ( + + + + + + )} + + {isRolesLoading ? ( +
+ +
+ ) : ( +
+
+ {projectRoles + ?.filter( + ({ name, slug }) => + name.toLowerCase().includes(searchRoles.toLowerCase()) || + slug.toLowerCase().includes(searchRoles.toLowerCase()) + ) + ?.map(({ id, name, slug }) => { + const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0]; + + return ( +
+
+ ( + { + field.onChange(isChecked); + setValue(`${slug}.temporaryAccess`, false); + }} + > + {name} + + )} + /> +
+
+ ( + { + setValue(`${slug}.isChecked`, true, { shouldDirty: true }); + field.onChange({ isTemporary: true, ...data }); + }} + onRemoveTemporary={() => { + setValue(`${slug}.isChecked`, false, { shouldDirty: true }); + field.onChange(false); + }} + /> + )} + /> +
+
+ ); + })} +
+
+
+ setSearchRoles(el.target.value)} + leftIcon={} + placeholder="Search roles.." + /> +
+
+ +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx new file mode 100644 index 000000000..b9638e644 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx @@ -0,0 +1,105 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + UpgradePlanModal +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useSubscription,useWorkspace } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useDeleteGroupFromWorkspace } from "@app/hooks/api"; + +import { GroupModal } from "./GroupModal"; +import { GroupTable } from "./GroupsTable"; + +export const GroupsSection = () => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + + const { mutateAsync: deleteMutateAsync } = useDeleteGroupFromWorkspace(); + + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "group", + "deleteGroup", + "upgradePlan" + ] as const); + + const handleAddGroupModal = () => { + if (!subscription?.groups) { + handlePopUpOpen("upgradePlan", { + description: "You can manage users more efficiently with groups if you upgrade your Infisical plan." + }); + } else { + handlePopUpOpen("group"); + } + } + + const onRemoveGroupSubmit = async (groupSlug: string) => { + try { + await deleteMutateAsync({ + groupSlug, + projectSlug: currentWorkspace?.slug || "" + }); + + createNotification({ + text: "Successfully removed identity from project", + type: "success" + }); + + handlePopUpClose("deleteGroup"); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to remove group from project"; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( +
+
+

Groups

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveGroupSubmit( + (popUp?.deleteGroup?.data as { slug: string })?.slug + ) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx new file mode 100644 index 000000000..5906accab --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx @@ -0,0 +1,103 @@ +import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + EmptyState, + IconButton, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useListWorkspaceGroups } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { GroupRoles } from "./GroupRoles"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteGroup", "group"]>, + data?: { + slug?: string; + name?: string; + } + ) => void; +}; + +export const GroupTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceGroups(currentWorkspace?.slug || ""); + return ( + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map(({ group: { id, name, slug }, roles, createdAt }) => { + return ( + + + + + + + ); + })} + +
NameRoleAdded on +
{name} + + {(isAllowed) => ( + + )} + + {format(new Date(createdAt), "yyyy-MM-dd")} + + {(isAllowed) => ( + { + handlePopUpOpen("deleteGroup", { + slug, + name + }); + }} + size="lg" + colorSchema="danger" + variant="plain" + ariaLabel="update" + className="ml-4" + isDisabled={!isAllowed} + > + + + )} + +
+ {!isLoading && data?.length === 0 && ( + + )} +
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/index.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/index.tsx new file mode 100644 index 000000000..7726fd170 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/index.tsx @@ -0,0 +1 @@ +export { GroupsSection } from "./GroupsSection"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/index.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/index.tsx new file mode 100644 index 000000000..7726fd170 --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/index.tsx @@ -0,0 +1 @@ +export { GroupsSection } from "./GroupsSection"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/index.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/index.tsx new file mode 100644 index 000000000..419579efc --- /dev/null +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/index.tsx @@ -0,0 +1 @@ +export { GroupsTab } from "./GroupsTab"; \ No newline at end of file diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index 4349b0913..4a57e2810 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -55,11 +55,17 @@ const SINGLE_PERMISSION_LIST = [ formName: "role" }, { - title: "Project Members", - subtitle: "Project members management control", + title: "User management", + subtitle: "Add, view and remove users from the project", icon: faUser, formName: "member" }, + { + title: "Group management", + subtitle: "Add, view and remove user groups from the project", + icon: faUsers, + formName: "groups" + }, { title: "Machine identity management", subtitle: "Add, view, update and remove (machine) identities from the project", diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts index d9ef81172..5dc90cf8b 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts @@ -37,6 +37,7 @@ export const formSchema = z.object({ .object({ secrets: z.record(multiEnvPermissionSchema).optional(), member: generalPermissionSchema, + groups: generalPermissionSchema, identity: generalPermissionSchema, role: generalPermissionSchema, integrations: generalPermissionSchema, diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx index 572325d4a..fd65cfd58 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx @@ -15,6 +15,7 @@ type Props = { formName: | "role" | "member" + | "groups" | "integrations" | "webhooks" | "service-tokens" diff --git a/frontend/src/views/Project/MembersPage/components/index.tsx b/frontend/src/views/Project/MembersPage/components/index.tsx index 30adfff22..153e904ac 100644 --- a/frontend/src/views/Project/MembersPage/components/index.tsx +++ b/frontend/src/views/Project/MembersPage/components/index.tsx @@ -1,3 +1,4 @@ +export { GroupsTab } from "./GroupsTab"; export { IdentityTab } from "./IdentityTab"; export { MemberListTab } from "./MemberListTab"; export { ProjectRoleListTab } from "./ProjectRoleListTab";