From 2b8ba793666571fba591948f68c2b69ef030322e Mon Sep 17 00:00:00 2001 From: = Date: Mon, 29 Sep 2025 14:14:48 +0530 Subject: [PATCH] feat: completed membership identity --- .../permission/permission-service-types.ts | 65 ++-- .../services/permission/permission-service.ts | 159 +++++--- .../membership-identity-dal.ts | 359 ++++++++++++++++++ .../membership-identity-service.ts | 325 ++++++++++++++++ .../membership-identity-types.ts | 82 ++++ .../namespace-membership-identity-factory.ts | 64 ++++ .../org/org-membership-identity-factory.ts | 130 +++++++ .../project-membership-identity-factory.ts | 210 ++++++++++ 8 files changed, 1305 insertions(+), 89 deletions(-) create mode 100644 backend/src/services/membership-identity/membership-identity-dal.ts create mode 100644 backend/src/services/membership-identity/membership-identity-service.ts create mode 100644 backend/src/services/membership-identity/membership-identity-types.ts create mode 100644 backend/src/services/membership-identity/namespace/namespace-membership-identity-factory.ts create mode 100644 backend/src/services/membership-identity/org/org-membership-identity-factory.ts create mode 100644 backend/src/services/membership-identity/project/project-membership-identity-factory.ts diff --git a/backend/src/ee/services/permission/permission-service-types.ts b/backend/src/ee/services/permission/permission-service-types.ts index ee97911e1..01da92a9b 100644 --- a/backend/src/ee/services/permission/permission-service-types.ts +++ b/backend/src/ee/services/permission/permission-service-types.ts @@ -2,7 +2,7 @@ import { MongoAbility } from "@casl/ability"; import { MongoQuery } from "@ucast/mongo2js"; import { Knex } from "knex"; -import { AccessScope, AccessScopeData, ActionProjectType, TMemberships } from "@app/db/schemas"; +import { ActionProjectType, TMemberships } from "@app/db/schemas"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { OrgPermissionSet } from "./org-permission"; @@ -95,39 +95,40 @@ export type TPermissionServiceFactory = { }[]; }>; // TODO(simp): switch to role dal later - getOrgPermissionByRole: ( - role: string, + getOrgPermissionByRoles: ( + roles: string[], orgId: string - ) => Promise<{ - permission: MongoAbility; - role?: { - name: string; - orgId: string; - id: string; - createdAt: Date; - updatedAt: Date; - slug: string; - permissions?: unknown; - description?: string | null | undefined; - }; - }>; - getProjectPermissionByRole: ( - role: string, + ) => Promise< + { + permission: MongoAbility; + role?: { + name: string; + id: string; + createdAt: Date; + updatedAt: Date; + slug: string; + permissions?: unknown; + description?: string | null | undefined; + }; + }[] + >; + getProjectPermissionByRoles: ( + roles: string[], projectId: string - ) => Promise<{ - permission: MongoAbility; - role?: { - name: string; - version: number; - id: string; - createdAt: Date; - updatedAt: Date; - projectId: string; - slug: string; - permissions?: unknown; - description?: string | null | undefined; - }; - }>; + ) => Promise< + { + permission: MongoAbility; + role?: { + name: string; + id: string; + createdAt: Date; + updatedAt: Date; + slug: string; + permissions?: unknown; + description?: string | null | undefined; + }; + }[] + >; checkGroupProjectPermission: ({ groupId, projectId, diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index b38c6e1b6..5071f3151 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -25,9 +25,8 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { objectify } from "@app/lib/fn"; import { ActorType } from "@app/services/auth/auth-type"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; -import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; -import { TProjectRoleDALFactory } from "@app/services/project-role/project-role-dal"; +import { TRoleDALFactory } from "@app/services/role/role-dal"; import { TServiceTokenDALFactory } from "@app/services/service-token/service-token-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -99,25 +98,23 @@ const buildProjectPermissionRules = (projectUserRoles: TBuildProjectPermissionDT }; type TPermissionServiceFactoryDep = { - orgRoleDAL: Pick; - projectRoleDAL: Pick; serviceTokenDAL: Pick; projectDAL: Pick; permissionDAL: TPermissionDALFactory; keyStore: TKeyStoreFactory; userDAL: Pick; identityDAL: Pick; + roleDAL: Pick; }; export const permissionServiceFactory = ({ permissionDAL, - orgRoleDAL, - projectRoleDAL, serviceTokenDAL, projectDAL, userDAL, identityDAL, - keyStore + keyStore, + roleDAL }: TPermissionServiceFactoryDep): TPermissionServiceFactory => { const invalidateProjectPermissionCache = async (projectId: string, tx?: Knex) => { const projectPermissionDalVersionKey = KeyStorePrefixes.ProjectPermissionDalVersion(projectId); @@ -245,33 +242,6 @@ export const permissionServiceFactory = ({ }; }; - // instead of actor type this will fetch by role slug. meaning it can be the pre defined slugs like - // admin member or user defined ones like biller etc - const getOrgPermissionByRole: TPermissionServiceFactory["getOrgPermissionByRole"] = async (role, orgId) => { - const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); - if (isCustomRole) { - const orgRole = await orgRoleDAL.findOne({ slug: role, orgId }); - if (!orgRole) - throw new NotFoundError({ - message: `Specified role '${role}' was not found in the organization with ID '${orgId}'` - }); - return { - permission: createMongoAbility( - buildOrgPermissionRules([{ role: OrgMembershipRole.Custom, permissions: orgRole.permissions }]), - { - conditionsMatcher - } - ), - role: orgRole - }; - } - return { - permission: createMongoAbility(buildOrgPermissionRules([{ role, permissions: [] }]), { - conditionsMatcher - }) - }; - }; - const getServiceTokenProjectPermission = async ({ serviceTokenId, projectId, @@ -572,30 +542,105 @@ export const permissionServiceFactory = ({ }; }; - const getProjectPermissionByRole: TPermissionServiceFactory["getProjectPermissionByRole"] = async ( - role, - projectId - ) => { - const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole); - if (isCustomRole) { - const projectRole = await projectRoleDAL.findOne({ slug: role, projectId }); - if (!projectRole) throw new NotFoundError({ message: `Specified role was not found: ${role}` }); - const rules = buildProjectPermissionRules([ - { role: ProjectMembershipRole.Custom, permissions: projectRole.permissions } - ]); - return { - permission: createMongoAbility(rules, { - conditionsMatcher - }), - role: projectRole - }; + // instead of actor type this will fetch by role slug. meaning it can be the pre defined slugs like + // admin member or user defined ones like biller etc + const getOrgPermissionByRoles: TPermissionServiceFactory["getOrgPermissionByRoles"] = async (roles, orgId) => { + const formattedRoles = roles.map((role) => ({ + name: role, + isCustom: !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole) + })); + + const customRoles = formattedRoles.filter((el) => el.isCustom).map((el) => el.name); + const customRoleDetails = customRoles.length + ? await roleDAL.find({ + orgId, + $in: { + slug: customRoles + } + }) + : []; + if (customRoles.length !== customRoleDetails.length) { + const missingRoles = customRoles.filter((role) => !customRoleDetails.find((el) => el.slug === role)); + throw new NotFoundError({ + message: `Specified roles '${missingRoles.join(",")}' was not found in the organization with ID '${orgId}'` + }); } - const rules = buildProjectPermissionRules([{ role, permissions: [] }]); - const permission = createMongoAbility(rules, { - conditionsMatcher + return formattedRoles.map((el) => { + if (el.isCustom) { + const roleDetails = customRoleDetails.find((role) => role.slug === el.name); + return { + permission: createMongoAbility( + buildOrgPermissionRules([{ role: OrgMembershipRole.Custom, permissions: roleDetails?.permissions || [] }]), + { + conditionsMatcher + } + ), + role: roleDetails! + }; + } + + return { + permission: createMongoAbility( + buildOrgPermissionRules([{ role: el.name, permissions: [] }]), + { + conditionsMatcher + } + ) + }; + }); + }; + + const getProjectPermissionByRoles: TPermissionServiceFactory["getProjectPermissionByRoles"] = async ( + roles, + projectId + ) => { + const formattedRoles = roles.map((role) => ({ + name: role, + isCustom: !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) + })); + + const customRoles = formattedRoles.filter((el) => el.isCustom).map((el) => el.name); + const customRoleDetails = customRoles.length + ? await roleDAL.find({ + projectId, + $in: { + slug: customRoles + } + }) + : []; + if (customRoles.length !== customRoleDetails.length) { + const missingRoles = customRoles.filter((role) => !customRoleDetails.find((el) => el.slug === role)); + throw new NotFoundError({ + message: `Specified roles '${missingRoles.join(",")}' was not found in the project with ID '${projectId}'` + }); + } + + return formattedRoles.map((el) => { + if (el.isCustom) { + const roleDetails = customRoleDetails.find((role) => role.slug === el.name); + return { + permission: createMongoAbility( + buildProjectPermissionRules([ + { role: ProjectMembershipRole.Custom, permissions: roleDetails?.permissions || [] } + ]), + { + conditionsMatcher + } + ), + role: roleDetails! + }; + } + + return { + permission: createMongoAbility( + buildProjectPermissionRules([{ role: el.name, permissions: [] }]), + { + conditionsMatcher + } + ) + }; }); - return { permission }; }; const checkGroupProjectPermission: TPermissionServiceFactory["checkGroupProjectPermission"] = async ({ @@ -626,8 +671,8 @@ export const permissionServiceFactory = ({ getOrgPermission, getProjectPermission, getProjectPermissions, - getOrgPermissionByRole, - getProjectPermissionByRole, + getOrgPermissionByRoles, + getProjectPermissionByRoles, checkGroupProjectPermission, invalidateProjectPermissionCache }; diff --git a/backend/src/services/membership-identity/membership-identity-dal.ts b/backend/src/services/membership-identity/membership-identity-dal.ts new file mode 100644 index 000000000..b51c04de4 --- /dev/null +++ b/backend/src/services/membership-identity/membership-identity-dal.ts @@ -0,0 +1,359 @@ +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"; + +import { buildAuthMethods } from "../identity/identity-fns"; + +export type TMembershipIdentityDALFactory = ReturnType; + +type TFindIdentityArg = { + scopeData: AccessScopeData; + tx?: Knex; + filter: Partial<{ + limit: number; + offset: number; + identityId: string; + name: Omit; + role: Omit; + }>; +}; + +type TGetIdentityByIdArg = { + scopeData: AccessScopeData; + tx?: Knex; + identityId: string; +}; + +export const membershipIdentityDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Membership); + + const getIdentityById = async ({ scopeData, tx, identityId }: TGetIdentityByIdArg) => { + try { + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .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}.actorIdentityId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where(`${TableName.Membership}.actorIdentityId`, identityId) + .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); + } + }) + .leftJoin( + TableName.IdentityUniversalAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityUniversalAuth}.identityId` + ) + .leftJoin(TableName.IdentityGcpAuth, `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId`) + .leftJoin( + TableName.IdentityAliCloudAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityAliCloudAuth}.identityId` + ) + .leftJoin(TableName.IdentityAwsAuth, `${TableName.Identity}.id`, `${TableName.IdentityAwsAuth}.identityId`) + .leftJoin( + TableName.IdentityKubernetesAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityKubernetesAuth}.identityId` + ) + .leftJoin(TableName.IdentityOciAuth, `${TableName.Identity}.id`, `${TableName.IdentityOciAuth}.identityId`) + .leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`) + .leftJoin(TableName.IdentityAzureAuth, `${TableName.Identity}.id`, `${TableName.IdentityAzureAuth}.identityId`) + .leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`) + .leftJoin( + TableName.IdentityTlsCertAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityTlsCertAuth}.identityId` + ) + .leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`) + .leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), + + 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"), + db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), + db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("alicloudId").withSchema(TableName.IdentityAliCloudAuth), + db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), + db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), + db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), + db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth) + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => { + const { + identityId: actorIdentityId, + identityHasDeleteProtection, + identityName, + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + alicloudId, + tokenId, + jwtId, + ociId, + ldapId, + tlsCertId + } = el; + return { + ...MembershipsSchema.parse(el), + identity: { + name: identityName, + id: actorIdentityId, + hasDeleteProtection: identityHasDeleteProtection, + authMethods: buildAuthMethods({ + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + tokenId, + alicloudId, + jwtId, + ldapId, + ociId, + tlsCertId + }) + } + }; + }, + 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: "MembershipGetByIdentityId" }); + } + }; + + const findIdentities = async ({ scopeData, tx, filter }: TFindIdentityArg) => { + try { + const paginatedIdentitys = (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .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.identityId) { + void qb.where(`${TableName.Identity}.id`, filter.identityId); + } + + 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 paginatedIdentitys.limit(filter.limit); + if (filter.offset) void paginatedIdentitys.offset(filter.offset); + + if (filter.name || filter.role) { + buildKnexFilterForSearchResource( + paginatedIdentitys, + { + name: filter.name!, + role: filter.role! + }, + (attr) => { + switch (attr) { + case "role": + return [`${TableName.Role}.slug`, `${TableName.MembershipRole}.role`]; + case "name": + return `${TableName.Identity}.name`; + default: + throw new BadRequestError({ message: `Invalid ${String(attr)} provided` }); + } + } + ); + } + + const docs = await (tx || db.replicaNode())(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .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`, paginatedIdentitys) + .select(selectAllTableCols(TableName.Membership)) + .select( + db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), + + 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}."actorIdentityId") OVER(PARTITION BY ${TableName.Membership}."scopeOrgId") as total` + ) + ); + + const data = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => { + const { identityId: actorIdentityId, identityHasDeleteProtection, identityName } = el; + return { + ...MembershipsSchema.parse(el), + identity: { + name: identityName, + id: actorIdentityId, + hasDeleteProtection: identityHasDeleteProtection + } + }; + }, + 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: "MembershipfindIdentity" }); + } + }; + + return { ...orm, findIdentities, getIdentityById }; +}; diff --git a/backend/src/services/membership-identity/membership-identity-service.ts b/backend/src/services/membership-identity/membership-identity-service.ts new file mode 100644 index 000000000..5d713ab67 --- /dev/null +++ b/backend/src/services/membership-identity/membership-identity-service.ts @@ -0,0 +1,325 @@ +import { AccessScope, ProjectMembershipRole, TemporaryPermissionMode, TMembershipRolesInsert } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +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 { TOrgDALFactory } from "../org/org-dal"; +import { TRoleDALFactory } from "../role/role-dal"; +import { TMembershipIdentityDALFactory } from "./membership-identity-dal"; +import { + TCreateMembershipIdentityDTO, + TDeleteMembershipIdentityDTO, + TGetMembershipIdentityByIdentityIdDTO, + TListMembershipIdentityDTO, + TUpdateMembershipIdentityDTO +} from "./membership-identity-types"; +import { newNamespaceMembershipIdentityFactory } from "./namespace/namespace-membership-identity-factory"; +import { newOrgMembershipIdentityFactory } from "./org/org-membership-identity-factory"; +import { newProjectMembershipIdentityFactory } from "./project/project-membership-identity-factory"; + +type TMembershipIdentityServiceFactoryDep = { + membershipIdentityDAL: TMembershipIdentityDALFactory; + membershipRoleDAL: Pick; + roleDAL: Pick; + permissionService: Pick< + TPermissionServiceFactory, + "getOrgPermission" | "getProjectPermission" | "getProjectPermissionByRoles" | "getOrgPermissionByRoles" + >; + orgDAL: Pick; +}; + +export type TMembershipIdentityServiceFactory = ReturnType; + +export const membershipIdentityServiceFactory = ({ + membershipIdentityDAL, + roleDAL, + membershipRoleDAL, + permissionService, + orgDAL +}: TMembershipIdentityServiceFactoryDep) => { + const scopeFactory = { + [AccessScope.Organization]: newOrgMembershipIdentityFactory({ + orgDAL, + permissionService + }), + [AccessScope.Project]: newProjectMembershipIdentityFactory({ + membershipIdentityDAL, + orgDAL, + permissionService + }), + [AccessScope.Namespace]: newNamespaceMembershipIdentityFactory({}) + }; + + const createMembership = async (dto: TCreateMembershipIdentityDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + const hasOnePermanentRole = data.roles.some((el) => el.isTemporary); + if (hasOnePermanentRole) { + throw new BadRequestError({ + message: "Identity 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.onCreateMembershipIdentityGuard(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 membershipIdentityDAL.transaction(async (tx) => { + const doc = await membershipIdentityDAL.create( + { + scope: scopeData.scope, + ...scopeDatabaseFields, + actorIdentityId: dto.permission.id + }, + 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: TUpdateMembershipIdentityDTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onUpdateMembershipIdentityGuard(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: "Identity 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 membershipIdentityDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorIdentityId: dto.selector.identityId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "Identity 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 membershipIdentityDAL.transaction(async (tx) => { + const doc = await membershipIdentityDAL.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: TDeleteMembershipIdentityDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onDeleteMembershipIdentityGuard(dto); + + const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + const existingMembership = await membershipIdentityDAL.findOne({ + scope: scopeData.scope, + ...scopeDatabaseFields, + actorIdentityId: dto.selector.identityId + }); + if (!existingMembership) + throw new BadRequestError({ + message: "Identity doesn't have membership" + }); + + if (existingMembership.actorIdentityId === dto.permission.id) + throw new BadRequestError({ + message: "You can't delete you own membership" + }); + + const membershipDoc = await membershipIdentityDAL.transaction(async (tx) => { + await membershipRoleDAL.delete({ membershipId: existingMembership.id }, tx); + const doc = await membershipIdentityDAL.deleteById(existingMembership.id, tx); + return doc; + }); + return { membership: membershipDoc }; + }; + + const listMemberships = async (dto: TListMembershipIdentityDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipIdentityGuard(dto); + const memberships = await membershipIdentityDAL.findIdentities({ + scopeData, + filter: { + limit: dto.data.limit, + offset: dto.data.offset, + name: dto.data.identityName + ? { + [SearchResourceOperators.$contains]: dto.data.identityName + } + : undefined, + role: dto.data.roles.length + ? { + [SearchResourceOperators.$in]: dto.data.roles + } + : undefined + } + }); + return memberships; + }; + + const getMembershipByIdentityId = async (dto: TGetMembershipIdentityByIdentityIdDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetMembershipIdentityByIdentityIdGuard(dto); + const membership = await membershipIdentityDAL.getIdentityById({ + scopeData, + identityId: selector.identityId + }); + if (!membership) throw new NotFoundError({ message: `Identity membership not found` }); + + return membership; + }; + + return { + createMembership, + updateMembership, + deleteMembership, + listMemberships, + getMembershipByIdentityId + }; +}; diff --git a/backend/src/services/membership-identity/membership-identity-types.ts b/backend/src/services/membership-identity/membership-identity-types.ts new file mode 100644 index 000000000..adce10237 --- /dev/null +++ b/backend/src/services/membership-identity/membership-identity-types.ts @@ -0,0 +1,82 @@ +import { AccessScopeData, TemporaryPermissionMode } from "@app/db/schemas"; +import { OrgServiceActor } from "@app/lib/types"; + +export interface TMembershipIdentityScopeFactory { + onCreateMembershipIdentityGuard: (arg: TCreateMembershipIdentityDTO) => Promise; + + onUpdateMembershipIdentityGuard: (arg: TUpdateMembershipIdentityDTO) => Promise; + onDeleteMembershipIdentityGuard: (arg: TDeleteMembershipIdentityDTO) => Promise; + onListMembershipIdentityGuard: (arg: TListMembershipIdentityDTO) => Promise; + onGetMembershipIdentityByIdentityIdGuard: (arg: TGetMembershipIdentityByIdentityIdDTO) => 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 TCreateMembershipIdentityDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + identityId: string; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TUpdateMembershipIdentityDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; + data: { + isActive?: boolean; + metadata?: { key: string; value: string }[]; + roles: { + role: string; + isTemporary: boolean; + temporaryMode?: TemporaryPermissionMode.Relative; + temporaryRange?: string; + temporaryAccessStartTime?: string; + }[]; + }; +}; + +export type TListMembershipIdentityDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; + data: { + limit?: number; + offset?: number; + identityName?: string; + roles: string[]; + }; +}; + +export type TDeleteMembershipIdentityDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; +}; + +export type TGetMembershipIdentityByIdentityIdDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; +}; diff --git a/backend/src/services/membership-identity/namespace/namespace-membership-identity-factory.ts b/backend/src/services/membership-identity/namespace/namespace-membership-identity-factory.ts new file mode 100644 index 000000000..55656778e --- /dev/null +++ b/backend/src/services/membership-identity/namespace/namespace-membership-identity-factory.ts @@ -0,0 +1,64 @@ +import { AccessScope } from "@app/db/schemas"; +import { InternalServerError } from "@app/lib/errors"; + +import { TMembershipIdentityScopeFactory } from "../membership-identity-types"; + +type TNamespaceMembershipIdentityScopeFactoryDep = Record; + +export const newNamespaceMembershipIdentityFactory = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deps: TNamespaceMembershipIdentityScopeFactoryDep +): TMembershipIdentityScopeFactory => { + const getScopeField: TMembershipIdentityScopeFactory["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: TMembershipIdentityScopeFactory["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: TMembershipIdentityScopeFactory["isCustomRole"] = () => { + throw new InternalServerError({ message: "Namespace membership user isCustomRole not implemented" }); + }; + + const onCreateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onCreateMembershipIdentityGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership user create not implemented" }); + }; + + const onUpdateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onUpdateMembershipIdentityGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership user update not implemented" }); + }; + + const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership user delete not implemented" }); + }; + + const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = async () => { + throw new InternalServerError({ message: "Namespace membership user list not implemented" }); + }; + + const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = + async () => { + throw new InternalServerError({ message: "Namespace membership user get by user id not implemented" }); + }; + + return { + onCreateMembershipIdentityGuard, + onUpdateMembershipIdentityGuard, + onDeleteMembershipIdentityGuard, + onListMembershipIdentityGuard, + onGetMembershipIdentityByIdentityIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-identity/org/org-membership-identity-factory.ts b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts new file mode 100644 index 000000000..2fe00bd4b --- /dev/null +++ b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts @@ -0,0 +1,130 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrgMembershipRole } from "@app/db/schemas"; +import { OrgPermissionIdentityActions, 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 { TMembershipIdentityScopeFactory } from "../membership-identity-types"; + +type TOrgMembershipIdentityScopeFactoryDep = { + permissionService: Pick; + orgDAL: Pick; +}; + +export const newOrgMembershipIdentityFactory = ({ + permissionService, + orgDAL +}: TOrgMembershipIdentityScopeFactoryDep): TMembershipIdentityScopeFactory => { + const getScopeField: TMembershipIdentityScopeFactory["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: TMembershipIdentityScopeFactory["getScopeDatabaseFields"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { scopeOrgId: dto.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const isCustomRole: TMembershipIdentityScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); + + const onCreateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onCreateMembershipIdentityGuard"] = + async () => { + throw new BadRequestError({ + message: "Organizatin membership cannot be created for organization scoped identity" + }); + }; + + const onUpdateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onUpdateMembershipIdentityGuard"] = 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(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + 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, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create identity org membership", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = + async () => { + throw new BadRequestError({ + message: "Organizatin membership cannot be created for organization scoped identity" + }); + }; + + const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = 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(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + }; + + const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = + 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(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + }; + + return { + onCreateMembershipIdentityGuard, + onUpdateMembershipIdentityGuard, + onDeleteMembershipIdentityGuard, + onListMembershipIdentityGuard, + onGetMembershipIdentityByIdentityIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +}; diff --git a/backend/src/services/membership-identity/project/project-membership-identity-factory.ts b/backend/src/services/membership-identity/project/project-membership-identity-factory.ts new file mode 100644 index 000000000..6ab04437c --- /dev/null +++ b/backend/src/services/membership-identity/project/project-membership-identity-factory.ts @@ -0,0 +1,210 @@ +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, + ProjectPermissionIdentityActions, + 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 { TMembershipIdentityDALFactory } from "../membership-identity-dal"; +import { TMembershipIdentityScopeFactory } from "../membership-identity-types"; + +type TProjectMembershipIdentityScopeFactoryDep = { + permissionService: Pick; + orgDAL: Pick; + membershipIdentityDAL: Pick; +}; + +export const newProjectMembershipIdentityFactory = ({ + permissionService, + orgDAL, + membershipIdentityDAL +}: TProjectMembershipIdentityScopeFactoryDep): TMembershipIdentityScopeFactory => { + const getScopeField: TMembershipIdentityScopeFactory["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: TMembershipIdentityScopeFactory["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: TMembershipIdentityScopeFactory["isCustomRole"] = (role) => isCustomProjectRole(role); + + const onCreateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onCreateMembershipIdentityGuard"] = 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( + ProjectPermissionIdentityActions.Create, + ProjectPermissionSub.Identity + ); + const orgMembership = await membershipIdentityDAL.findOne({ + actorIdentityId: dto.data.identityId, + scopeOrgId: dto.permission.orgId, + scope: AccessScope.Organization + }); + if (!orgMembership) + throw new BadRequestError({ message: `Identity ${dto.data.identityId} 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, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create identity project membership", + shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onUpdateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onUpdateMembershipIdentityGuard"] = 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( + ProjectPermissionIdentityActions.Edit, + ProjectPermissionSub.Identity + ); + + 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, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create identity project membership", + shouldUseNewPrivilegeSystem, + ProjectPermissionIdentityActions.GrantPrivileges, + ProjectPermissionSub.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; + + const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = 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( + ProjectPermissionIdentityActions.Delete, + ProjectPermissionSub.Identity + ); + }; + + const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = 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( + ProjectPermissionIdentityActions.Read, + ProjectPermissionSub.Identity + ); + }; + + const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = + 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( + ProjectPermissionIdentityActions.Read, + ProjectPermissionSub.Identity + ); + }; + + return { + onCreateMembershipIdentityGuard, + onUpdateMembershipIdentityGuard, + onDeleteMembershipIdentityGuard, + onListMembershipIdentityGuard, + onGetMembershipIdentityByIdentityIdGuard, + getScopeField, + getScopeDatabaseFields, + isCustomRole + }; +};