From ab0bddd87c5841bd0bc66cb7ba48c9d2a28d94b8 Mon Sep 17 00:00:00 2001 From: = Date: Sun, 28 Sep 2025 23:34:42 +0530 Subject: [PATCH] feat: wrappped up membership-user service --- .../services/permission/project-permission.ts | 4 + .../membership-user/membership-user-dal.ts | 255 +++++++++++ .../membership-user-service.ts | 400 ++++++++++++++++++ .../membership-user/membership-user-types.ts | 83 ++++ .../namespace-membership-user-factory.ts | 66 +++ .../org/org-membership-user-factory.ts | 108 +++++ .../project-membership-user-factory.ts | 121 ++++++ .../membership/membership-role-dal.ts | 10 + .../services/membership/membership-service.ts | 11 - .../services/membership/membership-types.ts | 0 10 files changed, 1047 insertions(+), 11 deletions(-) create mode 100644 backend/src/services/membership-user/membership-user-dal.ts create mode 100644 backend/src/services/membership-user/membership-user-service.ts create mode 100644 backend/src/services/membership-user/membership-user-types.ts create mode 100644 backend/src/services/membership-user/namespace/namespace-membership-user-factory.ts create mode 100644 backend/src/services/membership-user/org/org-membership-user-factory.ts create mode 100644 backend/src/services/membership-user/project/project-membership-user-factory.ts create mode 100644 backend/src/services/membership/membership-role-dal.ts delete mode 100644 backend/src/services/membership/membership-service.ts delete mode 100644 backend/src/services/membership/membership-types.ts diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 4c7f1faac..d2c5b30db 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -1,6 +1,7 @@ import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability"; import { z } from "zod"; +import { ProjectMembershipRole } from "@app/db/schemas"; import { CASL_ACTION_SCHEMA_ENUM, CASL_ACTION_SCHEMA_NATIVE_ENUM @@ -199,6 +200,9 @@ export enum ProjectPermissionPamSessionActions { // Terminate = "terminate" } +export const isCustomProjectRole = (slug: string) => + !Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); + export enum ProjectPermissionSub { Role = "role", Member = "member", diff --git a/backend/src/services/membership-user/membership-user-dal.ts b/backend/src/services/membership-user/membership-user-dal.ts new file mode 100644 index 000000000..e7be88bb1 --- /dev/null +++ b/backend/src/services/membership-user/membership-user-dal.ts @@ -0,0 +1,255 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { AccessScope, AccessScopeData, MembershipsSchema, TableName } from "@app/db/schemas"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db"; +import { TSearchResourceOperator } from "@app/lib/search-resource/search"; + +export type TMembershipUserDALFactory = ReturnType; + +type TFindUserArg = { + scopeData: AccessScopeData; + tx?: Knex; + filter: Partial<{ + limit: number; + offset: number; + userId?: string; + username: Omit; + role: Omit; + }>; +}; + +type TGetUserByIdArg = { + scopeData: AccessScopeData; + tx?: Knex; + userId: string; +}; + +export const membershipUserDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Membership); + + const getUserById = async ({ scopeData, tx, userId }: TGetUserByIdArg) => { + try { + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where(`${TableName.Membership}.actorUserId`, userId) + .where((qb) => { + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + } else if (scopeData.scope === AccessScope.Namespace) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Namespace) + .where(`${TableName.Membership}.scopeNamespaceId`, scopeData.namespaceId) + .whereNull(`${TableName.Membership}.scopeNamespaceId`); + } else if (scopeData.scope === AccessScope.Project) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("slug").withSchema(TableName.Role).as("roleSlug"), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("membershipRoleIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.MembershipRole).as("membershipRoleCreatedAt"), + db.ref("updatedAt").withSchema(TableName.MembershipRole).as("membershipRoleUpdatedAt"), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => MembershipsSchema.parse(el), + childrenMapper: [ + { + key: "membershipRoleId", + label: "roles" as const, + mapper: ({ + roleSlug, + membershipRoleId, + membershipRole, + membershipRoleIsTemporary, + membershipRoleTemporaryMode, + membershipRoleTemporaryRange, + membershipRoleTemporaryAccessEndTime, + membershipRoleTemporaryAccessStartTime, + membershipRoleCreatedAt, + membershipRoleUpdatedAt + }) => ({ + id: membershipRoleId, + role: membershipRole, + customRoleSlug: roleSlug, + temporaryRange: membershipRoleTemporaryRange, + temporaryMode: membershipRoleTemporaryMode, + temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: membershipRoleTemporaryAccessEndTime, + isTemporary: membershipRoleIsTemporary, + createdAt: membershipRoleCreatedAt, + updatedAt: membershipRoleUpdatedAt + }) + }, + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return data?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "MembershipGetByUserId" }); + } + }; + + const findUsers = async ({ scopeData, tx, filter }: TFindUserArg) => { + try { + const paginatedUsers = (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .distinct(`${TableName.Membership}.id`) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where((qb) => { + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scope`, AccessScope.Organization); + } else if (scopeData.scope === AccessScope.Namespace) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Namespace) + .where(`${TableName.Membership}.scopeNamespaceId`, scopeData.namespaceId) + .whereNull(`${TableName.Membership}.scopeNamespaceId`); + } else if (scopeData.scope === AccessScope.Project) { + void qb + .where(`${TableName.Membership}.scope`, AccessScope.Project) + .where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }); + + if (filter.limit) void paginatedUsers.limit(filter.limit); + if (filter.offset) void paginatedUsers.offset(filter.offset); + + if (filter.username || filter.role) { + buildKnexFilterForSearchResource( + paginatedUsers, + { + username: filter.username!, + role: filter.role! + }, + (attr) => { + switch (attr) { + case "role": + return [`${TableName.Role}.slug`, `${TableName.MembershipRole}.role`]; + case "username": + return `${TableName.Users}.name`; + default: + throw new BadRequestError({ message: `Invalid ${String(attr)} provided` }); + } + } + ); + } + + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .distinct(`${TableName.Membership}.id`) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .whereIn(`${TableName.Membership}.id`, paginatedUsers) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("slug").withSchema(TableName.Role).as("roleSlug"), + db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), + db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), + db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.MembershipRole).as("membershipRoleIsTemporary"), + db.ref("temporaryRange").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.MembershipRole) + .as("membershipRoleTemporaryAccessEndTime"), + db.ref("createdAt").withSchema(TableName.MembershipRole).as("membershipRoleCreatedAt"), + db.ref("updatedAt").withSchema(TableName.MembershipRole).as("membershipRoleUpdatedAt") + ) + .select( + db.raw( + `count(${TableName.Membership}."actorUserId") OVER(PARTITION BY ${TableName.Membership}."scopeOrgId") as total` + ) + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => MembershipsSchema.parse(el), + childrenMapper: [ + { + key: "membershipRoleId", + label: "roles" as const, + mapper: ({ + roleSlug, + membershipRoleId, + membershipRole, + membershipRoleIsTemporary, + membershipRoleTemporaryMode, + membershipRoleTemporaryRange, + membershipRoleTemporaryAccessEndTime, + membershipRoleTemporaryAccessStartTime, + membershipRoleCreatedAt, + membershipRoleUpdatedAt + }) => ({ + id: membershipRoleId, + role: membershipRole, + customRoleSlug: roleSlug, + temporaryRange: membershipRoleTemporaryRange, + temporaryMode: membershipRoleTemporaryMode, + temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: membershipRoleTemporaryAccessEndTime, + isTemporary: membershipRoleIsTemporary, + createdAt: membershipRoleCreatedAt, + updatedAt: membershipRoleUpdatedAt + }) + } + ] + }); + return { data, totalCount: Number((data?.[0] as unknown as { total: number })?.total ?? 0) }; + } catch (error) { + throw new DatabaseError({ error, name: "MembershipfindUser" }); + } + }; + + return { ...orm, findUsers, getUserById }; +}; diff --git a/backend/src/services/membership-user/membership-user-service.ts b/backend/src/services/membership-user/membership-user-service.ts new file mode 100644 index 000000000..73016b476 --- /dev/null +++ b/backend/src/services/membership-user/membership-user-service.ts @@ -0,0 +1,400 @@ +import { ProjectMembershipRole, TemporaryPermissionMode, TMembershipRolesInsert } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { groupBy } from "@app/lib/fn"; +import { ms } from "@app/lib/ms"; +import { SearchResourceOperators } from "@app/lib/search-resource/search"; + +import { AuthMethod } from "../auth/auth-type"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TRoleDALFactory } from "../role/role-dal"; +import { TUserDALFactory } from "../user/user-dal"; +import { TMembershipUserDALFactory } from "./membership-user-dal"; +import { + TCreateMembershipUserDTO, + TDeleteMembershipUserDTO, + TGetMembershipUserByUserIdDTO, + TListMembershipUserDTO, + TMembershipUserScopeFactory, + TUpdateMembershipUserDTO +} from "./membership-user-types"; + +type TMembershipUserServiceFactoryDep = { + membershipUserDAL: TMembershipUserDALFactory; + membershipRoleDAL: Pick; + licenseService: Pick; + roleDAL: Pick; + userDAL: Pick< + TUserDALFactory, + | "findUserByUsername" + | "find" + | "transaction" + | "create" + | "createUserEncryption" + | "findUserByEmail" + | "findUserEncKeyByUserId" + >; +}; + +export type TMembershipUserServiceFactory = ReturnType; + +export const membershipUserServiceFactory = ({ + membershipUserDAL, + roleDAL, + licenseService, + membershipRoleDAL, + userDAL +}: TMembershipUserServiceFactoryDep) => { + const scopeFactory: Record = {}; + + const $getUsers = async (usernames: string[]) => { + const existingUsers = await userDAL.find({ $in: { username: usernames } }); + if (existingUsers.length !== usernames.length) { + const newUserEmails = usernames.filter( + (inviteeEmail) => !existingUsers.find((el) => el.username === inviteeEmail) + ); + await userDAL.transaction(async (tx) => { + for await (const inviteeEmail of newUserEmails) { + const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx); + let inviteeUser = + usersByUsername?.length > 1 + ? usersByUsername.find((el) => el.username === inviteeEmail) + : usersByUsername?.[0]; + + // if the user doesn't exist we create the user with the email + if (!inviteeUser) { + // TODO(carlos): will be removed once the function receives usernames instead of emails + const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx); + if (usersByEmail?.length === 1) { + [inviteeUser] = usersByEmail; + } else { + inviteeUser = await userDAL.create( + { + isAccepted: false, + email: inviteeEmail, + username: inviteeEmail, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + } + } + + existingUsers.push(inviteeUser); + const inviteeUserId = inviteeUser?.id; + const existingEncrytionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx); + + // when user is missing the encrytion keys + // this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp + // So what we do is we generate a random secure password and then encrypt it with a random pub-private key + // Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password + if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncrytionKey)) { + await userDAL.createUserEncryption( + { + userId: inviteeUserId, + encryptionVersion: 2 + }, + tx + ); + } + } + }); + } + return existingUsers; + }; + + const createMembership = async (dto: TCreateMembershipUserDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + const hasOnePermanentRole = data.roles.some((el) => el.isTemporary); + if (hasOnePermanentRole) { + throw new BadRequestError({ + message: "User must have atleast one permanent role" + }); + } + const isInvalidTemporaryRole = data.roles.some((el) => { + if (el.isTemporary) { + if (!el.temporaryAccessStartTime || !el.temporaryRange) { + return true; + } + } + return false; + }); + if (isInvalidTemporaryRole) { + throw new BadRequestError({ + message: "Temporary role must have access start time and range" + }); + } + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const users = await $getUsers(dto.data.usernames); + const existingMemberships = await membershipUserDAL.find({ + scope: scopeData.scope, + ...scopeDatabaseFields, + $in: { + actorUserId: users.map((el) => el.id) + } + }); + // TODO(simp): do we really do this - check it out + if (existingMemberships.length === users.length) return { memberships: [] }; + + await factory.onCreateMembershipUserGuard(dto); + const newMembershipUsers = users.filter((user) => !existingMemberships?.find((el) => el.actorUserId === user.id)); + const newMemberships = newMembershipUsers.map((user) => ({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorUserId: user.id + })); + + const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); + const hasCustomRole = customInputRoles.length > 0; + if (hasCustomRole) { + const plan = await licenseService.getPlan(scopeData.orgId); + if (!plan?.rbac) + throw new BadRequestError({ + message: + "Failed to set custom default role due to plan RBAC restriction. Upgrade plan to set custom default org membership role." + }); + } + + const scopeField = factory.getScopeField(dto.scopeData); + const customRoles = hasCustomRole + ? await roleDAL.find({ + [scopeField.key]: scopeField.value, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) { + throw new NotFoundError({ message: "One or more custom roles not found" }); + } + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const membershipDoc = await membershipUserDAL.transaction(async (tx) => { + const docs = await membershipUserDAL.insertMany(newMemberships, tx); + + const roleDocs: TMembershipRolesInsert[] = []; + docs.forEach((membership) => { + data.roles.forEach((membershipRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]); + if (membershipRole.isTemporary) { + const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null; + roleDocs.push({ + membershipId: membership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null, + isTemporary: true, + temporaryMode: TemporaryPermissionMode.Relative, + temporaryRange: membershipRole.temporaryRange, + temporaryAccessStartTime: new Date(membershipRole.temporaryAccessStartTime as string), + temporaryAccessEndTime: new Date( + new Date(membershipRole.temporaryAccessStartTime as string).getTime() + (relativeTimeInMs as number) + ) + }); + } else { + roleDocs.push({ + membershipId: membership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null + }); + } + }); + }); + await membershipRoleDAL.insertMany(roleDocs, tx); + return docs; + }); + + return { memberships: membershipDoc }; + }; + + const updateMembership = async (dto: TUpdateMembershipUserDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onUpdateMembershipUserGuard(dto); + + const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); + const hasCustomRole = customInputRoles.length > 0; + if (hasCustomRole) { + const plan = await licenseService.getPlan(scopeData.orgId); + if (!plan?.rbac) + throw new BadRequestError({ + message: + "Failed to set custom default role due to plan RBAC restriction. Upgrade plan to set custom default org membership role." + }); + } + + const hasOnePermanentRole = data.roles.some((el) => el.isTemporary); + if (hasOnePermanentRole) { + throw new BadRequestError({ + message: "User must have atleast one permanent role" + }); + } + const isInvalidTemporaryRole = data.roles.some((el) => { + if (el.isTemporary) { + if (!el.temporaryAccessStartTime || !el.temporaryRange) { + return true; + } + } + return false; + }); + if (isInvalidTemporaryRole) { + throw new BadRequestError({ + message: "Temporary role must have access start time and range" + }); + } + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const existingMembership = await membershipUserDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorUserId: dto.selector.actorId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "User doesn't have membership" + }); + + const scopeField = factory.getScopeField(dto.scopeData); + const customRoles = hasCustomRole + ? await roleDAL.find({ + [scopeField.key]: scopeField.value, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) { + throw new NotFoundError({ message: "One or more custom roles not found" }); + } + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); + + const membershipDoc = await membershipUserDAL.transaction(async (tx) => { + const doc = await membershipUserDAL.updateById( + existingMembership.id, + { + isActive: data.isActive + }, + tx + ); + + const roleDocs: TMembershipRolesInsert[] = []; + data.roles.forEach((membershipRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[membershipRole.role]?.[0]); + if (membershipRole.isTemporary) { + const relativeTimeInMs = membershipRole.temporaryRange ? ms(membershipRole.temporaryRange) : null; + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null, + isTemporary: true, + temporaryMode: TemporaryPermissionMode.Relative, + temporaryRange: membershipRole.temporaryRange, + temporaryAccessStartTime: new Date(membershipRole.temporaryAccessStartTime as string), + temporaryAccessEndTime: new Date( + new Date(membershipRole.temporaryAccessStartTime as string).getTime() + (relativeTimeInMs as number) + ) + }); + } else { + roleDocs.push({ + membershipId: doc.id, + role: isCustomRole ? ProjectMembershipRole.Custom : membershipRole.role, + customRoleId: customRolesGroupBySlug[membershipRole.role] + ? customRolesGroupBySlug[membershipRole.role][0].id + : null + }); + } + }); + await membershipRoleDAL.delete( + { + membershipId: doc.id + }, + tx + ); + await membershipRoleDAL.insertMany(roleDocs, tx); + return doc; + }); + + return { memberships: membershipDoc }; + }; + + const deleteMembership = async (dto: TDeleteMembershipUserDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + const { actorIdOfDeletor } = await factory.onDeleteMembershipUserGuard(dto); + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const existingMembership = await membershipUserDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorUserId: dto.selector.actorId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "User doesn't have membership" + }); + + if (existingMembership.actorUserId === actorIdOfDeletor) + throw new BadRequestError({ + message: "You can delete you own membership" + }); + + const membershipDoc = await membershipUserDAL.transaction(async (tx) => { + await membershipRoleDAL.delete({ membershipId: existingMembership.id }, tx); + const doc = await membershipUserDAL.deleteById(existingMembership.id, tx); + return doc; + }); + return { membership: membershipDoc }; + }; + + const listMemberships = async (dto: TListMembershipUserDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipUserGuard(dto); + const memberships = await membershipUserDAL.findUsers({ + scopeData, + filter: { + limit: dto.data.limit, + offset: dto.data.offset, + username: dto.data.username, + role: dto.data.roles.length + ? { + [SearchResourceOperators.$in]: dto.data.roles + } + : undefined + } + }); + return memberships; + }; + + const getMembershipByUserId = async (dto: TGetMembershipUserByUserIdDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetMembershipUserByUserIdGuard(dto); + const membership = await membershipUserDAL.getUserById({ + scopeData, + userId: selector.actorId + }); + if (!membership) throw new NotFoundError({ message: `User membership not found` }); + + return membership; + }; + + return { + createMembership, + updateMembership, + deleteMembership, + listMemberships, + getMembershipByUserId + }; +}; diff --git a/backend/src/services/membership-user/membership-user-types.ts b/backend/src/services/membership-user/membership-user-types.ts new file mode 100644 index 000000000..a368e5a19 --- /dev/null +++ b/backend/src/services/membership-user/membership-user-types.ts @@ -0,0 +1,83 @@ +import { AccessScopeData, TemporaryPermissionMode } from "@app/db/schemas"; +import { OrgServiceActor } from "@app/lib/types"; + +export interface TMembershipUserScopeFactory { + onCreateMembershipUserGuard: (arg: TCreateMembershipUserDTO) => Promise; + onCreateMembershipComplete: (arg: { id: string; email: string }[]) => Promise; + + onUpdateMembershipUserGuard: (arg: TUpdateMembershipUserDTO) => Promise; + onDeleteMembershipUserGuard: (arg: TDeleteMembershipUserDTO) => Promise<{ actorIdOfDeletor: string }>; + onListMembershipUserGuard: (arg: TListMembershipUserDTO) => Promise; + onGetMembershipUserByUserIdGuard: (arg: TGetMembershipUserByUserIdDTO) => Promise; + getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; + getScopeDatabaseFields: (scope: AccessScopeData) => { + scopeOrgId: string; + scopeNamespaceId?: string | null; + scopeProjectId?: string | null; + }; + isCustomRole: (role: string) => boolean; +} + +export type TCreateMembershipUserDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + usernames: string[]; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TUpdateMembershipUserDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + actorId: string; + }; + data: { + isActive?: boolean; + metadata?: { key: string; value: string }[]; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TListMembershipUserDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + actorId: string; + }; + data: { + limit?: number; + offset?: number; + username?: string; + roles: string[]; + }; +}; + +export type TDeleteMembershipUserDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + actorId: string; + }; +}; + +export type TGetMembershipUserByUserIdDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + actorId: string; + }; +}; diff --git a/backend/src/services/membership-user/namespace/namespace-membership-user-factory.ts b/backend/src/services/membership-user/namespace/namespace-membership-user-factory.ts new file mode 100644 index 000000000..ca3d83b00 --- /dev/null +++ b/backend/src/services/membership-user/namespace/namespace-membership-user-factory.ts @@ -0,0 +1,66 @@ +import { AccessScope } from "@app/db/schemas"; +import { InternalServerError } from "@app/lib/errors"; + +import { TMembershipUserScopeFactory } from "../membership-user-types"; + +type TNamespaceMembershipUserScopeFactoryDep = Record; + +export const newNamespaceMembershipUserFactory = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deps: TNamespaceMembershipUserScopeFactoryDep +): TMembershipUserScopeFactory => { + const getScopeField: TMembershipUserScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { key: "namespaceId" as const, value: dto.namespaceId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the namespace factory" }); + }; + + const getScopeDatabaseFields: TMembershipUserScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { scopeOrgId: dto.orgId, scopeNamespaceId: dto.namespaceId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the namespace factory" }); + }; + + const isCustomRole: TMembershipUserScopeFactory["isCustomRole"] = () => { + throw new InternalServerError({ message: "Namespace membership user isCustomRole not implemented" }); + }; + + const onCreateMembershipUserGuard: TMembershipUserScopeFactory["onCreateMembershipUserGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership user create not implemented" }); + }; + + const onCreateMembershipComplete: TMembershipUserScopeFactory["onCreateMembershipComplete"] = async () => { + throw new InternalServerError({ message: "Namespace membership user create complete not implemented" }); + }; + + const onUpdateMembershipUserGuard: TMembershipUserScopeFactory["onUpdateMembershipUserGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership user update not implemented" }); + }; + + const onDeleteMembershipUserGuard: TMembershipUserScopeFactory["onDeleteMembershipUserGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership user delete not implemented" }); + }; + + const onListMembershipUserGuard: TMembershipUserScopeFactory["onListMembershipUserGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership user list not implemented" }); + }; + + const onGetMembershipUserByUserIdGuard: TMembershipUserScopeFactory["onGetMembershipUserByUserIdGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership user get by user id not implemented" }); + }; + + return { + onCreateMembershipUserGuard, + onCreateMembershipComplete, + onUpdateMembershipUserGuard, + onDeleteMembershipUserGuard, + onListMembershipUserGuard, + onGetMembershipUserByUserIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; \ No newline at end of file diff --git a/backend/src/services/membership-user/org/org-membership-user-factory.ts b/backend/src/services/membership-user/org/org-membership-user-factory.ts new file mode 100644 index 000000000..bdeb519f6 --- /dev/null +++ b/backend/src/services/membership-user/org/org-membership-user-factory.ts @@ -0,0 +1,108 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope } from "@app/db/schemas"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { InternalServerError } from "@app/lib/errors"; +import { isCustomOrgRole } from "@app/services/org/org-role-fns"; + +import { TMembershipUserScopeFactory } from "../membership-user-types"; + +type TOrgMembershipUserScopeFactoryDep = { + permissionService: Pick; +}; + +export const newOrgMembershipUserFactory = ({ + permissionService +}: TOrgMembershipUserScopeFactoryDep): TMembershipUserScopeFactory => { + const getScopeField: TMembershipUserScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { key: "orgId" as const, value: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const getScopeDatabaseFields: TMembershipUserScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { scopeOrgId: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const isCustomRole: TMembershipUserScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); + + const onCreateMembershipUserGuard: TMembershipUserScopeFactory["onCreateMembershipUserGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + }; + + const onCreateMembershipComplete: TMembershipUserScopeFactory["onCreateMembershipComplete"] = async () => { + // TODO(simp): fix this + throw new InternalServerError({ message: "Org membership user create complete not implemented" }); + }; + + const onUpdateMembershipUserGuard: TMembershipUserScopeFactory["onUpdateMembershipUserGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); + }; + + const onDeleteMembershipUserGuard: TMembershipUserScopeFactory["onDeleteMembershipUserGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); + return { actorIdOfDeletor: dto.permission.id }; + }; + + const onListMembershipUserGuard: TMembershipUserScopeFactory["onListMembershipUserGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + }; + + const onGetMembershipUserByUserIdGuard: TMembershipUserScopeFactory["onGetMembershipUserByUserIdGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + }; + + return { + onCreateMembershipUserGuard, + onCreateMembershipComplete, + onUpdateMembershipUserGuard, + onDeleteMembershipUserGuard, + onListMembershipUserGuard, + onGetMembershipUserByUserIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-user/project/project-membership-user-factory.ts b/backend/src/services/membership-user/project/project-membership-user-factory.ts new file mode 100644 index 000000000..9b02e6561 --- /dev/null +++ b/backend/src/services/membership-user/project/project-membership-user-factory.ts @@ -0,0 +1,121 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + isCustomProjectRole, + ProjectPermissionMemberActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { InternalServerError } from "@app/lib/errors"; + +import { TMembershipUserScopeFactory } from "../membership-user-types"; + +type TProjectMembershipUserScopeFactoryDep = { + permissionService: Pick; +}; + +export const newProjectMembershipUserFactory = ({ + permissionService +}: TProjectMembershipUserScopeFactoryDep): TMembershipUserScopeFactory => { + const getScopeField: TMembershipUserScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { key: "projectId" as const, value: dto.projectId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the project factory" }); + }; + + const getScopeDatabaseFields: TMembershipUserScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { scopeOrgId: dto.orgId, scopeProjectId: dto.projectId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the project factory" }); + }; + + const isCustomRole: TMembershipUserScopeFactory["isCustomRole"] = (role) => isCustomProjectRole(role); + + // TODO(simp): do rest of the shouldUsePrivilegeV2 check + const onCreateMembershipUserGuard: TMembershipUserScopeFactory["onCreateMembershipUserGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Create, ProjectPermissionSub.Member); + }; + + const onCreateMembershipComplete: TMembershipUserScopeFactory["onCreateMembershipComplete"] = async () => { + throw new InternalServerError({ message: "Project membership user create complete not implemented" }); + }; + + const onUpdateMembershipUserGuard: TMembershipUserScopeFactory["onUpdateMembershipUserGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Edit, ProjectPermissionSub.Member); + }; + + const onDeleteMembershipUserGuard: TMembershipUserScopeFactory["onDeleteMembershipUserGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Delete, ProjectPermissionSub.Member); + return { actorIdOfDeletor: dto.permission.id }; + }; + + const onListMembershipUserGuard: TMembershipUserScopeFactory["onListMembershipUserGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); + }; + + const onGetMembershipUserByUserIdGuard: TMembershipUserScopeFactory["onGetMembershipUserByUserIdGuard"] = async ( + dto + ) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); + }; + + return { + onCreateMembershipUserGuard, + onCreateMembershipComplete, + onUpdateMembershipUserGuard, + onDeleteMembershipUserGuard, + onListMembershipUserGuard, + onGetMembershipUserByUserIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership/membership-role-dal.ts b/backend/src/services/membership/membership-role-dal.ts new file mode 100644 index 000000000..bb7011221 --- /dev/null +++ b/backend/src/services/membership/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 TMembershipRoleDALFactory = ReturnType; + +export const membershipRoleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.MembershipRole); + return orm; +}; diff --git a/backend/src/services/membership/membership-service.ts b/backend/src/services/membership/membership-service.ts deleted file mode 100644 index c9dc34081..000000000 --- a/backend/src/services/membership/membership-service.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TMembershipDALFactory } from "./membership-dal"; - -type TMembershipServiceFactoryDep = { - membershipDAL: TMembershipDALFactory; -}; - -export type TMembershipServiceFactory = ReturnType; - -export const membershipServiceFactory = ({ membershipDAL }: TMembershipServiceFactoryDep) => { - return {}; -}; diff --git a/backend/src/services/membership/membership-types.ts b/backend/src/services/membership/membership-types.ts deleted file mode 100644 index e69de29bb..000000000