From 070548555c199bbd328eef9acf0894cc35031f4a Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Mon, 25 Mar 2024 12:21:41 +0100 Subject: [PATCH] feat: prevent OAuth client creation for non platform orgs (#14200) * feat: isPlatform flag for teams * feat: create organizations service to check if org is platform * feat: check if organization is platform in the OrganizationRolesGuard used by OAuthClientsController * remove unused variable * refactor: reduce nesting in create handler and abstract check in function * feat: dont create domain for platform orgs --- .../organization-roles.guard.ts | 55 +++-- .../oauth-clients.controller.e2e-spec.ts | 57 ++++- .../oauth-clients/oauth-client.module.ts | 3 +- .../organizations/organizations.module.ts | 11 + .../organizations/organizations.repository.ts | 15 ++ .../services/organizations.service.ts | 12 ++ .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../viewer/organizations/create.handler.ts | 197 +++++++++--------- .../viewer/organizations/create.schema.ts | 1 + 10 files changed, 239 insertions(+), 115 deletions(-) create mode 100644 apps/api/v2/src/modules/organizations/organizations.module.ts create mode 100644 apps/api/v2/src/modules/organizations/organizations.repository.ts create mode 100644 apps/api/v2/src/modules/organizations/services/organizations.service.ts create mode 100644 packages/prisma/migrations/20240325082604_add_is_platform_flag_to_team/migration.sql diff --git a/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts b/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts index 1063cec335..b410927cb1 100644 --- a/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/organization-roles/organization-roles.guard.ts @@ -1,32 +1,61 @@ import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; +import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; import { UserWithProfile } from "@/modules/users/users.repository"; -import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; +import { MembershipRole } from "@calcom/prisma/enums"; + @Injectable() export class OrganizationRolesGuard implements CanActivate { - constructor(private reflector: Reflector, private membershipRepository: MembershipsRepository) {} + constructor( + private reflector: Reflector, + private organizationsService: OrganizationsService, + private membershipRepository: MembershipsRepository + ) {} async canActivate(context: ExecutionContext): Promise { - const requiredRoles = this.reflector.get(Roles, context.getHandler()); - - if (!requiredRoles?.length || !Object.keys(requiredRoles)?.length) { - return true; - } - const request = context.switchToHttp().getRequest(); const user: UserWithProfile = request.user; const organizationId = user?.movedToProfile?.organizationId || user?.organizationId; if (!user || !organizationId) { - return false; + throw new ForbiddenException("No organization associated with the user."); } - const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id); - const isAccepted = membership.accepted; - const hasRequiredRole = requiredRoles.includes(membership.role); + await this.isPlatform(organizationId); - return isAccepted && hasRequiredRole; + const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id); + const allowedRoles = this.reflector.get(Roles, context.getHandler()); + + this.isMembershipAccepted(membership.accepted); + this.isRoleAllowed(membership.role, allowedRoles); + + return true; + } + + async isPlatform(organizationId: number) { + const isPlatform = await this.organizationsService.isPlatform(organizationId); + if (!isPlatform) { + throw new ForbiddenException("Organization is not a platform."); + } + } + + isMembershipAccepted(accepted: boolean) { + if (!accepted) { + throw new ForbiddenException(`User has not accepted membership in the organization.`); + } + } + + isRoleAllowed(membershipRole: MembershipRole, allowedRoles: MembershipRole[]) { + if (!allowedRoles?.length || !Object.keys(allowedRoles)?.length) { + return true; + } + + const hasRequiredRole = allowedRoles.includes(membershipRole); + if (!hasRequiredRole) { + throw new ForbiddenException(`User must have one of the roles: ${allowedRoles.join(", ")}.`); + } } } diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts index 0bebd3c54e..d6ee98661c 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller.e2e-spec.ts @@ -58,6 +58,58 @@ describe("OAuth Clients Endpoints", () => { }); }); + describe("Organization is not platform", () => { + let usersFixtures: UserRepositoryFixture; + let membershipFixtures: MembershipRepositoryFixture; + let teamFixtures: TeamRepositoryFixture; + let user: User; + let org: Team; + let app: INestApplication; + const userEmail = "test-e2e@api.com"; + + beforeAll(async () => { + const moduleRef = await withNextAuth( + userEmail, + Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule], + }) + ).compile(); + const strategy = moduleRef.get(NextAuthStrategy); + expect(strategy).toBeInstanceOf(NextAuthMockStrategy); + usersFixtures = new UserRepositoryFixture(moduleRef); + membershipFixtures = new MembershipRepositoryFixture(moduleRef); + teamFixtures = new TeamRepositoryFixture(moduleRef); + user = await usersFixtures.create({ + email: userEmail, + }); + org = await teamFixtures.create({ + name: "apiOrg", + metadata: { + isOrganization: true, + orgAutoAcceptEmail: "api.com", + isOrganizationVerified: true, + isOrganizationConfigured: true, + }, + isPlatform: false, + }); + await membershipFixtures.addUserToOrg(user, org, "ADMIN", true); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + }); + + it(`/GET`, () => { + return request(app.getHttpServer()).get("/api/v2/oauth-clients").expect(403); + }); + + afterAll(async () => { + await teamFixtures.delete(org.id); + await usersFixtures.delete(user.id); + await app.close(); + }); + }); + describe("User Is Authenticated", () => { let usersFixtures: UserRepositoryFixture; let membershipFixtures: MembershipRepositoryFixture; @@ -91,6 +143,7 @@ describe("OAuth Clients Endpoints", () => { isOrganizationVerified: true, isOrganizationConfigured: true, }, + isPlatform: true, }); app = moduleRef.createNestApplication(); bootstrap(app as NestExpressApplication); @@ -303,8 +356,8 @@ describe("OAuth Clients Endpoints", () => { }); afterAll(async () => { - teamFixtures.delete(org.id); - usersFixtures.delete(user.id); + await teamFixtures.delete(org.id); + await usersFixtures.delete(user.id); await app.close(); }); }); diff --git a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts index 8debe05a73..27dd6f985e 100644 --- a/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts +++ b/apps/api/v2/src/modules/oauth-clients/oauth-client.module.ts @@ -8,6 +8,7 @@ import { OAuthClientCredentialsGuard } from "@/modules/oauth-clients/guards/oaut import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { OrganizationsModule } from "@/modules/organizations/organizations.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { UsersModule } from "@/modules/users/users.module"; @@ -15,7 +16,7 @@ import { Global, Module } from "@nestjs/common"; @Global() @Module({ - imports: [PrismaModule, AuthModule, UsersModule, MembershipsModule, EventTypesModule], + imports: [PrismaModule, AuthModule, UsersModule, MembershipsModule, EventTypesModule, OrganizationsModule], providers: [ OAuthClientRepository, OAuthClientCredentialsGuard, diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts new file mode 100644 index 0000000000..0ec5c5d73f --- /dev/null +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -0,0 +1,11 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [OrganizationsRepository, OrganizationsService], + exports: [OrganizationsService], +}) +export class OrganizationsModule {} diff --git a/apps/api/v2/src/modules/organizations/organizations.repository.ts b/apps/api/v2/src/modules/organizations/organizations.repository.ts new file mode 100644 index 0000000000..98dfa20159 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/organizations.repository.ts @@ -0,0 +1,15 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsRepository { + constructor(private readonly dbRead: PrismaReadService) {} + + async findById(organizationId: number) { + return this.dbRead.prisma.team.findUnique({ + where: { + id: organizationId, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations.service.ts b/apps/api/v2/src/modules/organizations/services/organizations.service.ts new file mode 100644 index 0000000000..a32ed9e38b --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations.service.ts @@ -0,0 +1,12 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class OrganizationsService { + constructor(private readonly organizationsRepository: OrganizationsRepository) {} + + async isPlatform(organizationId: number) { + const organization = await this.organizationsRepository.findById(organizationId); + return organization?.isPlatform; + } +} diff --git a/packages/prisma/migrations/20240325082604_add_is_platform_flag_to_team/migration.sql b/packages/prisma/migrations/20240325082604_add_is_platform_flag_to_team/migration.sql new file mode 100644 index 0000000000..ca4dcc3ab2 --- /dev/null +++ b/packages/prisma/migrations/20240325082604_add_is_platform_flag_to_team/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "isPlatform" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index c4ba8ae48f..f1dea05277 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -378,6 +378,7 @@ model Team { orgProfiles Profile[] pendingPayment Boolean @default(false) dsyncTeamGroupMapping DSyncTeamGroupMapping[] + isPlatform Boolean @default(false) platformOAuthClient PlatformOAuthClient[] @@unique([slug, parentId]) diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index d804926229..c9b81fbdcd 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -36,7 +36,7 @@ const getIPAddress = async (url: string): Promise => { }; export const createHandler = async ({ input, ctx }: CreateOptions) => { - const { slug, name, adminEmail, adminUsername, check } = input; + const { slug, name, adminEmail, adminUsername, check, isPlatform } = input; const userCollisions = await prisma.user.findUnique({ where: { @@ -69,121 +69,120 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { const t = await getTranslation(ctx.user.locale ?? "en", "common"); const availability = getAvailabilityFromSchedule(DEFAULT_SCHEDULE); - let isOrganizationConfigured = false; - if (check === false) { - isOrganizationConfigured = await createDomain(slug); + if (check) { + await checkOrg(adminEmail, input.language); + return { checked: true }; + } - if (!isOrganizationConfigured) { - // Otherwise, we proceed to send an administrative email to admins regarding - // the need to configure DNS registry to support the newly created org - const instanceAdmins = await prisma.user.findMany({ - where: { role: UserPermissionRole.ADMIN }, - select: { email: true }, + const isOrganizationConfigured = isPlatform ? true : await createDomain(slug); + + if (!isOrganizationConfigured) { + // Otherwise, we proceed to send an administrative email to admins regarding + // the need to configure DNS registry to support the newly created org + const instanceAdmins = await prisma.user.findMany({ + where: { role: UserPermissionRole.ADMIN }, + select: { email: true }, + }); + if (instanceAdmins.length) { + await sendAdminOrganizationNotification({ + instanceAdmins, + orgSlug: slug, + ownerEmail: adminEmail, + webappIPAddress: await getIPAddress( + WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "") + ), + t, }); - if (instanceAdmins.length) { - await sendAdminOrganizationNotification({ - instanceAdmins, - orgSlug: slug, - ownerEmail: adminEmail, - webappIPAddress: await getIPAddress( - WEBAPP_URL.replace("https://", "")?.replace("http://", "").replace(/(:.*)/, "") - ), - t, - }); - } else { - console.warn("Organization created: subdomain not configured and couldn't notify adminnistrators"); - } + } else { + console.warn("Organization created: subdomain not configured and couldn't notify adminnistrators"); } + } - const { user: createOwnerOrg, organization } = await prisma.$transaction(async (tx) => { - const organization = await tx.team.create({ - data: { - name, - isOrganization: true, - ...(!IS_TEAM_BILLING_ENABLED ? { slug } : {}), - organizationSettings: { - create: { - isOrganizationVerified: true, - isOrganizationConfigured, - orgAutoAcceptEmail: emailDomain, - }, - }, - metadata: { - ...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: slug } : {}), + const { user: createOwnerOrg, organization } = await prisma.$transaction(async (tx) => { + const organization = await tx.team.create({ + data: { + name, + isOrganization: true, + ...(!IS_TEAM_BILLING_ENABLED ? { slug } : {}), + organizationSettings: { + create: { + isOrganizationVerified: true, + isOrganizationConfigured, + orgAutoAcceptEmail: emailDomain, }, }, - }); + metadata: { + ...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: slug } : {}), + }, + }, + }); - const user = await tx.user.create({ - data: { - username: slugify(adminUsername), - email: adminEmail, - emailVerified: new Date(), - password: { create: { hash: hashedPassword } }, - organizationId: organization.id, - // Default schedule - schedules: { - create: { - name: t("default_schedule_name"), - availability: { - createMany: { - data: availability.map((schedule) => ({ - days: schedule.days, - startTime: schedule.startTime, - endTime: schedule.endTime, - })), - }, + const user = await tx.user.create({ + data: { + username: slugify(adminUsername), + email: adminEmail, + emailVerified: new Date(), + password: { create: { hash: hashedPassword } }, + organizationId: organization.id, + // Default schedule + schedules: { + create: { + name: t("default_schedule_name"), + availability: { + createMany: { + data: availability.map((schedule) => ({ + days: schedule.days, + startTime: schedule.startTime, + endTime: schedule.endTime, + })), }, }, }, - profiles: { - create: { - username: slugify(adminUsername), - organizationId: organization.id, - uid: ProfileRepository.generateProfileUid(), - }, + }, + profiles: { + create: { + username: slugify(adminUsername), + organizationId: organization.id, + uid: ProfileRepository.generateProfileUid(), }, }, - }); - - await tx.membership.create({ - data: { - userId: user.id, - role: MembershipRole.OWNER, - accepted: true, - teamId: organization.id, - }, - }); - return { user, organization }; - }); - - if (!organization.id) throw Error("User not created"); - - return { user: { ...createOwnerOrg, organizationId: organization.id, password } }; - } else { - const language = await getTranslation(input.language ?? "en", "common"); - - const secret = createHash("md5") - .update(adminEmail + process.env.CALENDSO_ENCRYPTION_KEY) - .digest("hex"); - - totp.options = { step: 900 }; - const code = totp.generate(secret); - - await sendOrganizationEmailVerification({ - user: { - email: adminEmail, }, - code, - language, }); - } - // Sync Services: Close.com - //closeComUpsertOrganizationUser(createTeam, ctx.user, MembershipRole.OWNER); + await tx.membership.create({ + data: { + userId: user.id, + role: MembershipRole.OWNER, + accepted: true, + teamId: organization.id, + }, + }); + return { user, organization }; + }); - return { checked: true }; + if (!organization.id) throw Error("User not created"); + + return { user: { ...createOwnerOrg, organizationId: organization.id, password } }; }; +async function checkOrg(adminEmail: string, inputLanguage?: string) { + const language = await getTranslation(inputLanguage ?? "en", "common"); + + const secret = createHash("md5") + .update(adminEmail + process.env.CALENDSO_ENCRYPTION_KEY) + .digest("hex"); + + totp.options = { step: 900 }; + const code = totp.generate(secret); + + await sendOrganizationEmailVerification({ + user: { + email: adminEmail, + }, + code, + language, + }); +} + export default createHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/create.schema.ts b/packages/trpc/server/routers/viewer/organizations/create.schema.ts index d8c43ee3d1..2fe68d6e3c 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.schema.ts @@ -9,6 +9,7 @@ export const ZCreateInputSchema = z.object({ adminUsername: z.string(), check: z.boolean().default(true), language: z.string().optional(), + isPlatform: z.boolean().default(false), }); export type TCreateInputSchema = z.infer;