From 1571633d30c2b59e1df2aa808523821be1134c03 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 29 Sep 2025 14:39:47 +0530 Subject: [PATCH] feat: completed membership group identity --- .../membership-group/membership-group-dal.ts | 268 ++++++++++++++++ .../membership-group-service.ts | 303 ++++++++++++++++++ .../membership-group-types.ts | 82 +++++ .../namespace-membership-group-factory.ts | 61 ++++ .../org/org-membership-group-factory.ts | 125 ++++++++ .../project-membership-group-factory.ts | 188 +++++++++++ .../membership-identity-service.ts | 2 +- 7 files changed, 1028 insertions(+), 1 deletion(-) create mode 100644 backend/src/services/membership-group/membership-group-dal.ts create mode 100644 backend/src/services/membership-group/membership-group-service.ts create mode 100644 backend/src/services/membership-group/membership-group-types.ts create mode 100644 backend/src/services/membership-group/namespace/namespace-membership-group-factory.ts create mode 100644 backend/src/services/membership-group/org/org-membership-group-factory.ts create mode 100644 backend/src/services/membership-group/project/project-membership-group-factory.ts diff --git a/backend/src/services/membership-group/membership-group-dal.ts b/backend/src/services/membership-group/membership-group-dal.ts new file mode 100644 index 000000000..1e708b225 --- /dev/null +++ b/backend/src/services/membership-group/membership-group-dal.ts @@ -0,0 +1,268 @@ +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 TMembershipGroupDALFactory = ReturnType; + +type TFindGroupArg = { + scopeData: AccessScopeData; + tx?: Knex; + filter: Partial<{ + limit: number; + offset: number; + groupId: string; + name: Omit; + role: Omit; + }>; +}; + +type TGetGroupByIdArg = { + scopeData: AccessScopeData; + tx?: Knex; + groupId: string; +}; + +export const membershipGroupDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Membership); + + const getGroupById = async ({ scopeData, tx, groupId }: TGetGroupByIdArg) => { + try { + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorGroupId`) + .join(TableName.Groups, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) + .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where(`${TableName.Membership}.actorGroupId`, groupId) + .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("name").withSchema(TableName.Groups).as("groupName"), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug"), + 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") + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => { + const { groupName, groupSlug } = el; + return { + ...MembershipsSchema.parse(el), + group: { + id: groupId, + name: groupName, + slug: groupSlug + } + }; + }, + 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?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "MembershipGetByGroupId" }); + } + }; + + const findGroups = async ({ scopeData, tx, filter }: TFindGroupArg) => { + try { + const paginatedGroups = (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorGroupId`) + .join(TableName.Groups, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .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 (filter.groupId) { + void qb.where(`${TableName.Groups}.id`, filter.groupId); + } + + 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 paginatedGroups.limit(filter.limit); + if (filter.offset) void paginatedGroups.offset(filter.offset); + + if (filter.name || filter.role) { + buildKnexFilterForSearchResource( + paginatedGroups, + { + name: filter.name!, + role: filter.role! + }, + (attr) => { + switch (attr) { + case "role": + return [`${TableName.Role}.slug`, `${TableName.MembershipRole}.role`]; + case "name": + return `${TableName.Groups}.name`; + default: + throw new BadRequestError({ message: `Invalid ${String(attr)} provided` }); + } + } + ); + } + + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorGroupId`) + .join(TableName.Groups, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .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`, paginatedGroups) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("name").withSchema(TableName.Groups).as("groupName"), + db.ref("slug").withSchema(TableName.Groups).as("groupSlug"), + db.ref("id").withSchema(TableName.Groups).as("groupId"), + + 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}."actorGroupId") OVER(PARTITION BY ${TableName.Membership}."scopeOrgId") as total` + ) + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => { + const { groupId, groupName, groupSlug } = el; + return { + ...MembershipsSchema.parse(el), + group: { + id: groupId, + name: groupName, + slug: groupSlug + } + }; + }, + 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: "MembershipfindGroup" }); + } + }; + + return { ...orm, findGroups, getGroupById }; +}; diff --git a/backend/src/services/membership-group/membership-group-service.ts b/backend/src/services/membership-group/membership-group-service.ts new file mode 100644 index 000000000..ac3bd708a --- /dev/null +++ b/backend/src/services/membership-group/membership-group-service.ts @@ -0,0 +1,303 @@ +import { ProjectMembershipRole, TemporaryPermissionMode, TMembershipRolesInsert } from "@app/db/schemas"; +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 { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TRoleDALFactory } from "../role/role-dal"; +import { TMembershipGroupDALFactory } from "./membership-group-dal"; +import { + TCreateMembershipGroupDTO, + TDeleteMembershipGroupDTO, + TGetMembershipGroupByGroupIdDTO, + TListMembershipGroupDTO, + TMembershipGroupScopeFactory, + TUpdateMembershipGroupDTO +} from "./membership-group-types"; + +type TMembershipGroupServiceFactoryDep = { + membershipGroupDAL: TMembershipGroupDALFactory; + membershipRoleDAL: Pick; + roleDAL: Pick; +}; + +export type TMembershipGroupServiceFactory = ReturnType; + +export const membershipGroupServiceFactory = ({ + membershipGroupDAL, + roleDAL, + membershipRoleDAL +}: TMembershipGroupServiceFactoryDep) => { + const scopeFactory: Record = {}; + + const createMembership = async (dto: TCreateMembershipGroupDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + const hasOnePermanentRole = data.roles.some((el) => el.isTemporary); + if (hasOnePermanentRole) { + throw new BadRequestError({ + message: "Group 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); + await factory.onCreateMembershipGroupGuard(dto); + + const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); + const hasCustomRole = customInputRoles.length > 0; + + 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 membership = await membershipGroupDAL.transaction(async (tx) => { + const doc = await membershipGroupDAL.create( + { + scope: scopeData.scope, + ...scopeDatabaseFields, + actorGroupId: dto.data.groupId + }, + 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.insertMany(roleDocs, tx); + return doc; + }); + + return { membership }; + }; + + const updateMembership = async (dto: TUpdateMembershipGroupDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onUpdateMembershipGroupGuard(dto); + + const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); + const hasCustomRole = customInputRoles.length > 0; + + const hasOnePermanentRole = data.roles.some((el) => el.isTemporary); + if (hasOnePermanentRole) { + throw new BadRequestError({ + message: "Group 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 membershipGroupDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorIdentityId: dto.selector.groupId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "Group 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 membershipGroupDAL.transaction(async (tx) => { + const doc = await membershipGroupDAL.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 { membership: membershipDoc }; + }; + + const deleteMembership = async (dto: TDeleteMembershipGroupDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onDeleteMembershipGroupGuard(dto); + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const existingMembership = await membershipGroupDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorIdentityId: dto.selector.groupId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "Group doesn't have membership" + }); + + if (existingMembership.actorIdentityId === dto.permission.id) + throw new BadRequestError({ + message: "You can't delete you own membership" + }); + + const membershipDoc = await membershipGroupDAL.transaction(async (tx) => { + await membershipRoleDAL.delete({ membershipId: existingMembership.id }, tx); + const doc = await membershipGroupDAL.deleteById(existingMembership.id, tx); + return doc; + }); + return { membership: membershipDoc }; + }; + + const listMemberships = async (dto: TListMembershipGroupDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipGroupGuard(dto); + const memberships = await membershipGroupDAL.findGroups({ + scopeData, + filter: { + limit: dto.data.limit, + offset: dto.data.offset, + name: dto.data.groupName + ? { + [SearchResourceOperators.$contains]: dto.data.groupName + } + : undefined, + role: dto.data.roles.length + ? { + [SearchResourceOperators.$in]: dto.data.roles + } + : undefined + } + }); + return memberships; + }; + + const getMembershipByGroupId = async (dto: TGetMembershipGroupByGroupIdDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetMembershipGroupByGroupIdGuard(dto); + const membership = await membershipGroupDAL.getGroupById({ + scopeData, + groupId: selector.groupId + }); + if (!membership) throw new NotFoundError({ message: `Group membership not found` }); + + return membership; + }; + + return { + createMembership, + updateMembership, + deleteMembership, + listMemberships, + getMembershipByGroupId + }; +}; diff --git a/backend/src/services/membership-group/membership-group-types.ts b/backend/src/services/membership-group/membership-group-types.ts new file mode 100644 index 000000000..8b2c852cf --- /dev/null +++ b/backend/src/services/membership-group/membership-group-types.ts @@ -0,0 +1,82 @@ +import { AccessScopeData, TemporaryPermissionMode } from "@app/db/schemas"; +import { OrgServiceActor } from "@app/lib/types"; + +export interface TMembershipGroupScopeFactory { + onCreateMembershipGroupGuard: (arg: TCreateMembershipGroupDTO) => Promise; + + onUpdateMembershipGroupGuard: (arg: TUpdateMembershipGroupDTO) => Promise; + onDeleteMembershipGroupGuard: (arg: TDeleteMembershipGroupDTO) => Promise; + onListMembershipGroupGuard: (arg: TListMembershipGroupDTO) => Promise; + onGetMembershipGroupByGroupIdGuard: (arg: TGetMembershipGroupByGroupIdDTO) => 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 TCreateMembershipGroupDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + groupId: string; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TUpdateMembershipGroupDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + groupId: string; + }; + data: { + isActive?: boolean; + metadata?: { key: string; value: string }[]; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TListMembershipGroupDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + groupId: string; + }; + data: { + limit?: number; + offset?: number; + groupName?: string; + roles: string[]; + }; +}; + +export type TDeleteMembershipGroupDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + groupId: string; + }; +}; + +export type TGetMembershipGroupByGroupIdDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + groupId: string; + }; +}; diff --git a/backend/src/services/membership-group/namespace/namespace-membership-group-factory.ts b/backend/src/services/membership-group/namespace/namespace-membership-group-factory.ts new file mode 100644 index 000000000..5c8973b1b --- /dev/null +++ b/backend/src/services/membership-group/namespace/namespace-membership-group-factory.ts @@ -0,0 +1,61 @@ +import { AccessScope } from "@app/db/schemas"; +import { InternalServerError } from "@app/lib/errors"; + +import { TMembershipGroupScopeFactory } from "../membership-group-types"; + +type TNamespaceMembershipGroupScopeFactoryDep = Record; + +export const newNamespaceMembershipGroupFactory = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deps: TNamespaceMembershipGroupScopeFactoryDep +): TMembershipGroupScopeFactory => { + const getScopeField: TMembershipGroupScopeFactory["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: TMembershipGroupScopeFactory["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: TMembershipGroupScopeFactory["isCustomRole"] = () => { + throw new InternalServerError({ message: "Namespace membership group isCustomRole not implemented" }); + }; + + const onCreateMembershipGroupGuard: TMembershipGroupScopeFactory["onCreateMembershipGroupGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership group create not implemented" }); + }; + + const onUpdateMembershipGroupGuard: TMembershipGroupScopeFactory["onUpdateMembershipGroupGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership group update not implemented" }); + }; + + const onDeleteMembershipGroupGuard: TMembershipGroupScopeFactory["onDeleteMembershipGroupGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership group delete not implemented" }); + }; + + const onListMembershipGroupGuard: TMembershipGroupScopeFactory["onListMembershipGroupGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership group list not implemented" }); + }; + + const onGetMembershipGroupByGroupIdGuard: TMembershipGroupScopeFactory["onGetMembershipGroupByGroupIdGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership group get by group id not implemented" }); + }; + + return { + onCreateMembershipGroupGuard, + onUpdateMembershipGroupGuard, + onDeleteMembershipGroupGuard, + onListMembershipGroupGuard, + onGetMembershipGroupByGroupIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-group/org/org-membership-group-factory.ts b/backend/src/services/membership-group/org/org-membership-group-factory.ts new file mode 100644 index 000000000..edc889851 --- /dev/null +++ b/backend/src/services/membership-group/org/org-membership-group-factory.ts @@ -0,0 +1,125 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrgMembershipRole } from "@app/db/schemas"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, InternalServerError, PermissionBoundaryError } from "@app/lib/errors"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { isCustomOrgRole } from "@app/services/org/org-role-fns"; + +import { TMembershipGroupScopeFactory } from "../membership-group-types"; + +type TOrgMembershipGroupScopeFactoryDep = { + permissionService: Pick; + orgDAL: Pick; +}; + +export const newOrgMembershipGroupFactory = ({ + permissionService, + orgDAL +}: TOrgMembershipGroupScopeFactoryDep): TMembershipGroupScopeFactory => { + const getScopeField: TMembershipGroupScopeFactory["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: TMembershipGroupScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { scopeOrgId: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const isCustomRole: TMembershipGroupScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); + + const onCreateMembershipGroupGuard: TMembershipGroupScopeFactory["onCreateMembershipGroupGuard"] = async () => { + throw new BadRequestError({ + message: "Organizatin membership cannot be created for groups" + }); + }; + + const onUpdateMembershipGroupGuard: TMembershipGroupScopeFactory["onUpdateMembershipGroupGuard"] = 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(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); + const permissionRoles = await permissionService.getOrgPermissionByRoles( + dto.data.roles.map((el) => el.role), + dto.permission.orgId + ); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== OrgMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.GrantPrivileges, + OrgPermissionSubjects.Groups, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create group org membership", + shouldUseNewPrivilegeSystem, + OrgPermissionGroupActions.GrantPrivileges, + OrgPermissionSubjects.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onDeleteMembershipGroupGuard: TMembershipGroupScopeFactory["onDeleteMembershipGroupGuard"] = async () => { + throw new BadRequestError({ + message: "Organizatin membership cannot be created for organization scoped group" + }); + }; + + const onListMembershipGroupGuard: TMembershipGroupScopeFactory["onListMembershipGroupGuard"] = 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(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); + }; + + const onGetMembershipGroupByGroupIdGuard: TMembershipGroupScopeFactory["onGetMembershipGroupByGroupIdGuard"] = 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(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); + }; + + return { + onCreateMembershipGroupGuard, + onUpdateMembershipGroupGuard, + onDeleteMembershipGroupGuard, + onListMembershipGroupGuard, + onGetMembershipGroupByGroupIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-group/project/project-membership-group-factory.ts b/backend/src/services/membership-group/project/project-membership-group-factory.ts new file mode 100644 index 000000000..6900e3755 --- /dev/null +++ b/backend/src/services/membership-group/project/project-membership-group-factory.ts @@ -0,0 +1,188 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; +import { + constructPermissionErrorMessage, + validatePrivilegeChangeOperation +} from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + isCustomProjectRole, + ProjectPermissionGroupActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, InternalServerError, PermissionBoundaryError } from "@app/lib/errors"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; + +import { TMembershipGroupDALFactory } from "../membership-group-dal"; +import { TMembershipGroupScopeFactory } from "../membership-group-types"; + +type TProjectMembershipGroupScopeFactoryDep = { + permissionService: Pick; + orgDAL: Pick; + membershipGroupDAL: Pick; +}; + +export const newProjectMembershipGroupFactory = ({ + permissionService, + orgDAL, + membershipGroupDAL +}: TProjectMembershipGroupScopeFactoryDep): TMembershipGroupScopeFactory => { + const getScopeField: TMembershipGroupScopeFactory["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: TMembershipGroupScopeFactory["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: TMembershipGroupScopeFactory["isCustomRole"] = (role) => isCustomProjectRole(role); + + const onCreateMembershipGroupGuard: TMembershipGroupScopeFactory["onCreateMembershipGroupGuard"] = 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(ProjectPermissionGroupActions.Create, ProjectPermissionSub.Groups); + const orgMembership = await membershipGroupDAL.findOne({ + actorGroupId: dto.data.groupId, + scopeOrgId: dto.permission.orgId, + scope: AccessScope.Organization + }); + if (!orgMembership) + throw new BadRequestError({ message: `Group ${dto.data.groupId} is missing organization membership` }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + const permissionRoles = await permissionService.getProjectPermissionByRoles( + dto.data.roles.map((el) => el.role), + scope.value + ); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== ProjectMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create group project membership", + shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onUpdateMembershipGroupGuard: TMembershipGroupScopeFactory["onUpdateMembershipGroupGuard"] = 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(ProjectPermissionGroupActions.Edit, ProjectPermissionSub.Groups); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + const permissionRoles = await permissionService.getProjectPermissionByRoles( + dto.data.roles.map((el) => el.role), + scope.value + ); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== ProjectMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create group project membership", + shouldUseNewPrivilegeSystem, + ProjectPermissionGroupActions.GrantPrivileges, + ProjectPermissionSub.Groups + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onDeleteMembershipGroupGuard: TMembershipGroupScopeFactory["onDeleteMembershipGroupGuard"] = 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(ProjectPermissionGroupActions.Delete, ProjectPermissionSub.Groups); + }; + + const onListMembershipGroupGuard: TMembershipGroupScopeFactory["onListMembershipGroupGuard"] = 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(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); + }; + + const onGetMembershipGroupByGroupIdGuard: TMembershipGroupScopeFactory["onGetMembershipGroupByGroupIdGuard"] = 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(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); + }; + + return { + onCreateMembershipGroupGuard, + onUpdateMembershipGroupGuard, + onDeleteMembershipGroupGuard, + onListMembershipGroupGuard, + onGetMembershipGroupByGroupIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-identity/membership-identity-service.ts b/backend/src/services/membership-identity/membership-identity-service.ts index 5d713ab67..20f99fda3 100644 --- a/backend/src/services/membership-identity/membership-identity-service.ts +++ b/backend/src/services/membership-identity/membership-identity-service.ts @@ -101,7 +101,7 @@ export const membershipIdentityServiceFactory = ({ { scope: scopeData.scope, ...scopeDatabaseFields, - actorIdentityId: dto.permission.id + actorIdentityId: dto.data.identityId }, tx );