From 68b93385eb2903fc50dee8c2422d6dbfad069b61 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:38:29 +0200 Subject: [PATCH] refactor: platform billing controller auth guards (#25559) * refactor: platform billing controller auth guards * fixup! refactor: platform billing controller auth guards --- .../v2/src/modules/billing/billing.module.ts | 2 ++ .../billing.controller.e2e-spec.ts | 16 +++++++-- .../billing/controllers/billing.controller.ts | 24 +++++++------ .../billing/guards/is-user-in-billing-org.ts | 35 +++++++++++++++++++ 4 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 apps/api/v2/src/modules/billing/guards/is-user-in-billing-org.ts diff --git a/apps/api/v2/src/modules/billing/billing.module.ts b/apps/api/v2/src/modules/billing/billing.module.ts index 7752233c7c..5c75bb59e7 100644 --- a/apps/api/v2/src/modules/billing/billing.module.ts +++ b/apps/api/v2/src/modules/billing/billing.module.ts @@ -2,6 +2,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/reposito import { BillingProcessor } from "@/modules/billing/billing.processor"; import { BillingRepository } from "@/modules/billing/billing.repository"; import { BillingController } from "@/modules/billing/controllers/billing.controller"; +import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org"; import { BillingServiceCachingProxy } from "@/modules/billing/services/billing-service-caching-proxy"; import { BillingConfigService } from "@/modules/billing/services/billing.config.service"; import { BillingService } from "@/modules/billing/services/billing.service"; @@ -44,6 +45,7 @@ import { Module } from "@nestjs/common"; ManagedOrganizationsBillingService, OAuthClientRepository, BookingsRepository_2024_08_13, + IsUserInBillingOrg, ], exports: [BillingService, BillingRepository, ManagedOrganizationsBillingService], controllers: [BillingController], diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts index ee4960ad85..73d95298ed 100644 --- a/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.e2e-spec.ts @@ -17,7 +17,7 @@ import { OrganizationRepositoryFixture } from "test/fixtures/repository/organiza import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { randomString } from "test/utils/randomString"; -import { withNextAuth } from "test/utils/withNextAuth"; +import { withApiAuth } from "test/utils/withApiAuth"; import type { Team, PlatformBilling } from "@calcom/prisma/client"; @@ -32,8 +32,10 @@ describe("Platform Billing Controller (e2e)", () => { let membershipsRepositoryFixture: MembershipRepositoryFixture; let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture; let organization: Team; + let organization2: Team; + beforeAll(async () => { - const moduleRef = await withNextAuth( + const moduleRef = await withApiAuth( userEmail, Test.createTestingModule({ imports: [AppModule, PrismaModule, UsersModule, TokensModule], @@ -49,6 +51,10 @@ describe("Platform Billing Controller (e2e)", () => { name: `billing-organization-${randomString()}`, isPlatform: true, }); + organization2 = await organizationsRepositoryFixture.create({ + name: `billing-organization-2-${randomString()}`, + isPlatform: true, + }); user = await userRepositoryFixture.create({ email: userEmail, @@ -133,7 +139,7 @@ describe("Platform Billing Controller (e2e)", () => { }); }); - it("/billing/webhook (GET) should get billing plan for org", () => { + it("/billing/:orgId/check (GET) should check billing plan for org", () => { return request(app.getHttpServer()) .get(`/v2/billing/${organization.id}/check`) .expect(200) @@ -143,6 +149,10 @@ describe("Platform Billing Controller (e2e)", () => { }); }); + it("/billing/:organizationId/check (GET) should not be able to check other org plan", () => { + return request(app.getHttpServer()).get(`/v2/billing/${organization2.id}/check`).expect(403); + }); + it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => { jest.spyOn(StripeService.prototype, "getStripe").mockImplementation( () => diff --git a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts index 572d29f666..b192561223 100644 --- a/apps/api/v2/src/modules/billing/controllers/billing.controller.ts +++ b/apps/api/v2/src/modules/billing/controllers/billing.controller.ts @@ -1,11 +1,13 @@ import { AppConfig } from "@/config/type"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator"; import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator"; -import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard"; import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input"; import { CheckPlatformBillingResponseDto } from "@/modules/billing/controllers/outputs/CheckPlatformBillingResponse.dto"; import { SubscribeTeamToBillingResponseDto } from "@/modules/billing/controllers/outputs/SubscribeTeamToBillingResponse.dto"; +import { IsUserInBillingOrg } from "@/modules/billing/guards/is-user-in-billing-org"; import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface"; import { StripeService } from "@/modules/stripe/stripe.service"; import { @@ -49,8 +51,9 @@ export class BillingController { } @Get("/:teamId/check") - @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg) @MembershipRoles(["OWNER", "ADMIN", "MEMBER"]) + @ApiAuthGuardOnlyAllow(["NEXT_AUTH"]) async checkTeamBilling( @Param("teamId", ParseIntPipe) teamId: number ): Promise> { @@ -66,8 +69,9 @@ export class BillingController { } @Post("/:teamId/subscribe") - @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg) @MembershipRoles(["OWNER", "ADMIN"]) + @ApiAuthGuardOnlyAllow(["NEXT_AUTH"]) async subscribeTeamToStripe( @Param("teamId") teamId: number, @Body() input: SubscribeToPlanInput @@ -84,8 +88,9 @@ export class BillingController { } @Post("/:teamId/upgrade") - @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg) @MembershipRoles(["OWNER", "ADMIN"]) + @ApiAuthGuardOnlyAllow(["NEXT_AUTH"]) async upgradeTeamBillingInStripe( @Param("teamId") teamId: number, @Body() input: SubscribeToPlanInput @@ -100,13 +105,12 @@ export class BillingController { }; } - @Delete("/:organizationId/unsubscribe") - @UseGuards(NextAuthGuard, OrganizationRolesGuard) + @Delete("/:teamId/unsubscribe") + @UseGuards(ApiAuthGuard, OrganizationRolesGuard, IsUserInBillingOrg) @MembershipRoles(["OWNER", "ADMIN"]) - async cancelTeamSubscriptionInStripe( - @Param("organizationId") organizationId: number - ): Promise { - await this.billingService.cancelTeamSubscription(organizationId); + @ApiAuthGuardOnlyAllow(["NEXT_AUTH"]) + async cancelTeamSubscriptionInStripe(@Param("teamId") teamId: number): Promise { + await this.billingService.cancelTeamSubscription(teamId); return { status: "success", diff --git a/apps/api/v2/src/modules/billing/guards/is-user-in-billing-org.ts b/apps/api/v2/src/modules/billing/guards/is-user-in-billing-org.ts new file mode 100644 index 0000000000..6187650867 --- /dev/null +++ b/apps/api/v2/src/modules/billing/guards/is-user-in-billing-org.ts @@ -0,0 +1,35 @@ +import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +import type { Team } from "@calcom/prisma/client"; + +@Injectable() +export class IsUserInBillingOrg implements CanActivate { + constructor(private organizationsRepository: OrganizationsRepository) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const orgId: string = request.params.teamId; + const userId = (request.user as ApiAuthGuardUser).id; + + if (!userId) { + throw new ForbiddenException("IsUserInBillingOrg - No user id found."); + } + + if (!orgId) { + throw new ForbiddenException("IsUserInBillingOrg - No org id found in request params."); + } + + const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId)); + + if (!user) { + throw new ForbiddenException( + `IsUserInBillingOrg - user with id=${userId} is not part of the organization with id=${orgId}.` + ); + } + + return true; + } +}