diff --git a/backend/src/ee/services/permission/permission-service-types.ts b/backend/src/ee/services/permission/permission-service-types.ts index 0519a71fa..ee97911e1 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 { ActionProjectType, TMemberships } from "@app/db/schemas"; +import { AccessScope, AccessScopeData, ActionProjectType, TMemberships } from "@app/db/schemas"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { OrgPermissionSet } from "./org-permission"; @@ -49,6 +49,14 @@ export type TGetProjectPermissionArg = { actionProjectType: ActionProjectType; }; +export type TGetOrgPermissionArg = { + actor: ActorType; + actorId: string; + orgId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId?: string; +}; + export type TPermissionServiceFactory = { getOrgPermission: ( type: ActorType, diff --git a/backend/src/services/role/namespace/namespace-role-factory.ts b/backend/src/services/role/namespace/namespace-role-factory.ts new file mode 100644 index 000000000..482f35f56 --- /dev/null +++ b/backend/src/services/role/namespace/namespace-role-factory.ts @@ -0,0 +1,41 @@ +import { AccessScope } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError } from "@app/lib/errors"; + +import { TRoleScopeFactory } from "../role-types"; + +type TNamespaceRoleScopeFactoryDep = { + permissionService: Pick; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const newNamespaceRoleFactory = (_dto: TNamespaceRoleScopeFactoryDep): TRoleScopeFactory => { + const onCreateRoleGuard: TRoleScopeFactory["onCreateRoleGuard"] = async () => {}; + + const onUpdateRoleGuard: TRoleScopeFactory["onUpdateRoleGuard"] = async () => {}; + + const onDeleteRoleGuard: TRoleScopeFactory["onDeleteRoleGuard"] = async () => {}; + + const onListRoleGuard: TRoleScopeFactory["onListRoleGuard"] = async () => {}; + + const onGetRoleByIdGuard: TRoleScopeFactory["onGetRoleByIdGuard"] = async () => {}; + + const onGetRoleBySlugGuard: TRoleScopeFactory["onGetRoleBySlugGuard"] = async () => {}; + + const getScopeField: TRoleScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Namespace) { + return { key: "namespaceId" as const, value: dto.namespaceId }; + } + throw new BadRequestError({ message: "Invalid scope provided for the factory" }); + }; + + return { + onCreateRoleGuard, + onUpdateRoleGuard, + onDeleteRoleGuard, + onListRoleGuard, + onGetRoleByIdGuard, + onGetRoleBySlugGuard, + getScopeField + }; +}; diff --git a/backend/src/services/role/org/org-role-factory.ts b/backend/src/services/role/org/org-role-factory.ts new file mode 100644 index 000000000..5b54dc4e8 --- /dev/null +++ b/backend/src/services/role/org/org-role-factory.ts @@ -0,0 +1,97 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope } from "@app/db/schemas"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError } from "@app/lib/errors"; + +import { TRoleScopeFactory } from "../role-types"; + +type TOrgRoleScopeFactoryDep = { + permissionService: Pick; +}; + +export const newOrgRoleFactory = ({ permissionService }: TOrgRoleScopeFactoryDep): TRoleScopeFactory => { + const getScopeField: TRoleScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Organization) { + return { key: "orgId" as const, value: dto.orgId }; + } + throw new BadRequestError({ message: "Invalid scope provided for the factory" }); + }; + + const onCreateRoleGuard: TRoleScopeFactory["onCreateRoleGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Role); + }; + + const onUpdateRoleGuard: TRoleScopeFactory["onUpdateRoleGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Role); + }; + + const onDeleteRoleGuard: TRoleScopeFactory["onDeleteRoleGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); + }; + + const onListRoleGuard: TRoleScopeFactory["onListRoleGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + }; + + const onGetRoleByIdGuard: TRoleScopeFactory["onGetRoleByIdGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + }; + + const onGetRoleBySlugGuard: TRoleScopeFactory["onGetRoleBySlugGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission( + dto.permission.type, + dto.permission.id, + dto.permission.orgId, + dto.permission.authMethod, + dto.permission.orgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + }; + + return { + onCreateRoleGuard, + onUpdateRoleGuard, + onDeleteRoleGuard, + onListRoleGuard, + onGetRoleByIdGuard, + onGetRoleBySlugGuard, + getScopeField + }; +}; diff --git a/backend/src/services/role/project/project-role-factory.ts b/backend/src/services/role/project/project-role-factory.ts new file mode 100644 index 000000000..9967c379e --- /dev/null +++ b/backend/src/services/role/project/project-role-factory.ts @@ -0,0 +1,109 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; + +import { TRoleScopeFactory } from "../role-types"; + +type TProjectRoleScopeFactoryDep = { + permissionService: Pick; +}; + +export const newProjectRoleFactory = ({ permissionService }: TProjectRoleScopeFactoryDep): TRoleScopeFactory => { + const getScopeField: TRoleScopeFactory["getScopeField"] = (dto) => { + if (dto.scope === AccessScope.Project) { + return { key: "projectId" as const, value: dto.projectId }; + } + throw new BadRequestError({ message: "Invalid scope provided for the factory" }); + }; + + const onCreateRoleGuard: TRoleScopeFactory["onCreateRoleGuard"] = 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(ProjectPermissionActions.Create, ProjectPermissionSub.Role); + }; + + const onUpdateRoleGuard: TRoleScopeFactory["onUpdateRoleGuard"] = 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(ProjectPermissionActions.Edit, ProjectPermissionSub.Role); + }; + + const onDeleteRoleGuard: TRoleScopeFactory["onDeleteRoleGuard"] = 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(ProjectPermissionActions.Delete, ProjectPermissionSub.Role); + }; + + const onListRoleGuard: TRoleScopeFactory["onListRoleGuard"] = 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(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + }; + + const onGetRoleByIdGuard: TRoleScopeFactory["onGetRoleByIdGuard"] = 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(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + }; + + const onGetRoleBySlugGuard: TRoleScopeFactory["onGetRoleBySlugGuard"] = 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(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + }; + + return { + onCreateRoleGuard, + onUpdateRoleGuard, + onDeleteRoleGuard, + onListRoleGuard, + onGetRoleByIdGuard, + onGetRoleBySlugGuard, + getScopeField + }; +}; diff --git a/backend/src/services/role/role-dal.ts b/backend/src/services/role/role-dal.ts new file mode 100644 index 000000000..9f3d7cf61 --- /dev/null +++ b/backend/src/services/role/role-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TRoleDALFactory = ReturnType; + +export const roleDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Role); + return orm; +}; diff --git a/backend/src/services/role/role-service.ts b/backend/src/services/role/role-service.ts new file mode 100644 index 000000000..3611749e7 --- /dev/null +++ b/backend/src/services/role/role-service.ts @@ -0,0 +1,176 @@ +import { AccessScope, TableName } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; + +import { newNamespaceRoleFactory } from "./namespace/namespace-role-factory"; +import { newOrgRoleFactory } from "./org/org-role-factory"; +import { newProjectRoleFactory } from "./project/project-role-factory"; +import { TRoleDALFactory } from "./role-dal"; +import { + TCreateRoleDTO, + TDeleteRoleDTO, + TGetRoleByIdDTO, + TGetRoleBySlugDTO, + TListRoleDTO, + TUpdateRoleDTO +} from "./role-types"; + +type TRoleServiceFactoryDep = { + roleDAL: TRoleDALFactory; + permissionService: Pick; +}; + +export type TRoleServiceFactory = ReturnType; + +export const roleServiceFactory = ({ roleDAL, permissionService }: TRoleServiceFactoryDep) => { + const orgRoleFactory = newOrgRoleFactory({ + permissionService + }); + const projectRoleFactory = newProjectRoleFactory({ + permissionService + }); + const namespaceRoleFactory = newNamespaceRoleFactory({ + permissionService + }); + const scopeFactory = { + [AccessScope.Organization]: orgRoleFactory, + [AccessScope.Project]: projectRoleFactory, + [AccessScope.Namespace]: namespaceRoleFactory + }; + + const createRole = async (dto: TCreateRoleDTO) => { + const { data, scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + await factory.onCreateRoleGuard(dto); + + validateHandlebarTemplate("Role Creation", JSON.stringify(data.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); + + const scope = factory.getScopeField(scopeData); + const role = await roleDAL.create({ + name: data.name, + description: data.description, + slug: data.slug, + permissions: data.permissions, + [scope.key]: scope.value + }); + + return { ...role, [scope.key]: scope.value }; + }; + + const updateRole = async (dto: TUpdateRoleDTO) => { + const { data, scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + const scope = factory.getScopeField(scopeData); + + await factory.onUpdateRoleGuard(dto); + + const existingRole = await roleDAL.findOne({ + id: dto.selector.id, + [scope.key]: scope.value + }); + if (!existingRole) throw new NotFoundError({ message: `Role with ${dto.selector.id} not found` }); + + if (data.slug) { + const existingSlug = await roleDAL.findOne({ + slug: data.slug, + [scope.key]: scope.value + }); + if (existingSlug && existingRole.id !== existingSlug.id) + throw new BadRequestError({ message: `Role with ${data.slug} not found` }); + } + + validateHandlebarTemplate("Role Update", JSON.stringify(data.permissions || []), { + allowedExpressions: (val) => val.includes("identity.") + }); + + const role = await roleDAL.updateById(existingRole.id, { + name: data?.name, + description: data?.description, + slug: data?.slug, + permissions: data?.permissions + }); + + return { ...role, [scope.key]: scope.value }; + }; + + const deleteRole = async (dto: TDeleteRoleDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + const scope = factory.getScopeField(scopeData); + await factory.onDeleteRoleGuard(dto); + + const existingRole = await roleDAL.findOne({ + id: dto.selector.id, + [scope.key]: scope.value + }); + if (!existingRole) throw new NotFoundError({ message: `Role with ${dto.selector.id} not found` }); + + const [role] = await roleDAL.delete({ + id: existingRole.id, + [scope.key]: scope.value + }); + + return { ...role, [scope.key]: scope.value }; + }; + + const listRoles = async (dto: TListRoleDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListRoleGuard(dto); + + const scope = factory.getScopeField(scopeData); + const roles = await roleDAL.find( + { + [scope.key]: scope.value + }, + { limit: dto.data.limit, offset: dto.data.offset, sort: [[`${TableName.Role}.slug` as "slug", "asc"]] } + ); + + return { roles }; + }; + + const getRoleById = async (dto: TGetRoleByIdDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetRoleByIdGuard(dto); + + const scope = factory.getScopeField(scopeData); + const role = await roleDAL.findOne({ + id: selector.id, + [scope.key]: scope.value + }); + if (!role) throw new NotFoundError({ message: `Role with id ${dto.selector.id} not found` }); + + return { ...role, [scope.key]: scope.value }; + }; + + const getRoleBySlug = async (dto: TGetRoleBySlugDTO) => { + const { scopeData, selector } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetRoleBySlugGuard(dto); + + const scope = factory.getScopeField(scopeData); + const role = await roleDAL.findOne({ + slug: selector.slug, + [scope.key]: scope.value + }); + if (!role) throw new NotFoundError({ message: `Role with slug ${dto.selector.slug} not found` }); + + return { ...role, [scope.key]: scope.value }; + }; + + return { + createRole, + updateRole, + deleteRole, + listRoles, + getRoleById, + getRoleBySlug + }; +}; diff --git a/backend/src/services/role/role-types.ts b/backend/src/services/role/role-types.ts new file mode 100644 index 000000000..a82ce7c4c --- /dev/null +++ b/backend/src/services/role/role-types.ts @@ -0,0 +1,70 @@ +import { AccessScopeData } from "@app/db/schemas"; +import { OrgServiceActor } from "@app/lib/types"; + +export interface TRoleScopeFactory { + onCreateRoleGuard: (arg: TCreateRoleDTO) => Promise; + onUpdateRoleGuard: (arg: TUpdateRoleDTO) => Promise; + onDeleteRoleGuard: (arg: TDeleteRoleDTO) => Promise; + onListRoleGuard: (arg: TListRoleDTO) => Promise; + onGetRoleByIdGuard: (arg: TGetRoleByIdDTO) => Promise; + onGetRoleBySlugGuard: (arg: TGetRoleBySlugDTO) => Promise; + getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; +} + +export type TCreateRoleDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + name: string; + description?: string; + slug: string; + permissions: unknown; + }; +}; + +export type TUpdateRoleDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + id: string; + }; + data: Partial<{ + name: string; + description?: string; + slug: string; + permissions: unknown; + }>; +}; + +export type TListRoleDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + limit?: number; + offset?: number; + }; +}; + +export type TDeleteRoleDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + id: string; + }; +}; + +export type TGetRoleByIdDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + id: string; + }; +}; + +export type TGetRoleBySlugDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + slug: string; + }; +};