diff --git a/apps/api/v1/lib/utils/isAdmin.ts b/apps/api/v1/lib/utils/isAdmin.ts index 1caf210a98..cd551fbe6e 100644 --- a/apps/api/v1/lib/utils/isAdmin.ts +++ b/apps/api/v1/lib/utils/isAdmin.ts @@ -19,6 +19,9 @@ export const isAdminGuard = async (req: NextApiRequest) => { accepted: true, team: { isOrganization: true, + organizationSettings: { + isAdminAPIEnabled: true, + }, }, OR: [{ role: MembershipRole.OWNER }, { role: MembershipRole.ADMIN }], }, diff --git a/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts b/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts index 43dca2c9e7..0e1563d40c 100644 --- a/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts +++ b/apps/api/v1/test/lib/utils/isAdmin.integration-test.ts @@ -58,7 +58,7 @@ describe("isAdmin guard", () => { expect(scope).toBe(ScopeOfAdmin.SystemWide); }); - it("Returns org-wide admin when user is set as such", async () => { + it("Returns org-wide admin when user is set as such & admin API access is granted", async () => { const { req } = createMocks({ method: "POST", body: {}, @@ -67,10 +67,21 @@ describe("isAdmin guard", () => { const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } }); req.userId = adminUser.id; - const { isAdmin, scope } = await isAdminGuard(req); - expect(isAdmin).toBe(true); expect(scope).toBe(ScopeOfAdmin.OrgOwnerOrAdmin); }); + + it("Returns no admin when user is set as org admin but admin API access is revoked", async () => { + const { req } = createMocks({ + method: "POST", + body: {}, + }); + + const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } }); + + req.userId = adminUser.id; + const { isAdmin } = await isAdminGuard(req); + expect(isAdmin).toBe(false); + }); }); diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts new file mode 100644 index 0000000000..a922160c6d --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/organizations/is-admin-api-enabled.guard.ts @@ -0,0 +1,62 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +import { Team } from "@calcom/prisma/client"; + +type CachedData = { + org?: Team; + canAccess?: boolean; +}; + +@Injectable() +export class IsAdminAPIEnabledGuard implements CanActivate { + constructor( + private organizationsRepository: OrganizationsRepository, + private readonly redisService: RedisService + ) {} + + async canActivate(context: ExecutionContext): Promise { + let canAccess = false; + const request = context.switchToHttp().getRequest(); + const organizationId: string = request.params.orgId; + + if (!organizationId) { + throw new ForbiddenException("No organization id found in request params."); + } + + const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`; + const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY); + + if (cachedData) { + const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData; + if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) { + request.organization = cachedOrg; + return cachedCanAccess; + } + } + + const org = await this.organizationsRepository.findById(Number(organizationId)); + + if (org?.isOrganization && !org?.isPlatform) { + const adminAPIAccessIsEnabledInOrg = await this.organizationsRepository.fetchOrgAdminApiStatus( + Number(organizationId) + ); + if (!adminAPIAccessIsEnabledInOrg) { + throw new ForbiddenException( + `Organization does not have Admin API access, please contact https://cal.com/sales to upgrade` + ); + } + } + canAccess = true; + org && + (await this.redisService.redis.set( + REDIS_CACHE_KEY, + JSON.stringify({ org: org, canAccess } satisfies CachedData), + "EX", + 300 + )); + return canAccess; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts index 511c245a48..8f4574d1e2 100644 --- a/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller.e2e-spec.ts @@ -15,6 +15,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { PlatformOAuthClient, Team, User } from "@prisma/client"; import * as request from "supertest"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; @@ -57,7 +58,7 @@ describe("OAuthFlow Endpoints", () => { let app: INestApplication; let usersRepositoryFixtures: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture; let profilesRepositoryFixture: ProfileRepositoryFixture; @@ -83,7 +84,7 @@ describe("OAuthFlow Endpoints", () => { await app.init(); oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); usersRepositoryFixtures = new UserRepositoryFixture(moduleRef); profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts index b93314fcf1..fa1c41df66 100644 --- a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.controller.ts @@ -4,6 +4,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; @@ -49,7 +50,7 @@ export class OrganizationsEventTypesController { @Roles("TEAM_ADMIN") @PlatformPlan("ESSENTIALS") - @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard) + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) @Post("/teams/:teamId/event-types") async createTeamEventType( @GetUser() user: UserWithProfile, @@ -72,7 +73,7 @@ export class OrganizationsEventTypesController { @Roles("TEAM_ADMIN") @PlatformPlan("ESSENTIALS") - @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard) + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) @Get("/teams/:teamId/event-types/:eventTypeId") async getTeamEventType( @Param("teamId", ParseIntPipe) teamId: number, @@ -90,7 +91,7 @@ export class OrganizationsEventTypesController { }; } - @UseGuards(IsOrgGuard, IsTeamInOrg) + @UseGuards(IsOrgGuard, IsTeamInOrg, IsAdminAPIEnabledGuard) @Get("/teams/:teamId/event-types") async getTeamEventTypes( @Param("teamId", ParseIntPipe) teamId: number, @@ -116,7 +117,7 @@ export class OrganizationsEventTypesController { @Roles("TEAM_ADMIN") @PlatformPlan("ESSENTIALS") - @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard) + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) @Get("/teams/event-types") async getTeamsEventTypes( @Param("orgId", ParseIntPipe) orgId: number, @@ -133,7 +134,7 @@ export class OrganizationsEventTypesController { @Roles("TEAM_ADMIN") @PlatformPlan("ESSENTIALS") - @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard) + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) @Patch("/teams/:teamId/event-types/:eventTypeId") async updateTeamEventType( @Param("teamId", ParseIntPipe) teamId: number, @@ -156,7 +157,7 @@ export class OrganizationsEventTypesController { @Roles("TEAM_ADMIN") @PlatformPlan("ESSENTIALS") - @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard) + @UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) @Delete("/teams/:teamId/event-types/:eventTypeId") @HttpCode(HttpStatus.OK) async deleteTeamEventType( diff --git a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts index 2c4857c259..f09961fb4d 100644 --- a/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/event-types/organizations-event-types.e2e-spec.ts @@ -10,6 +10,7 @@ import { User } from "@prisma/client"; import * as request from "supertest"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; @@ -30,7 +31,7 @@ describe("Organizations Event Types Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let teamsRepositoryFixture: TeamRepositoryFixture; let membershipsRepositoryFixture: MembershipRepositoryFixture; let profileRepositoryFixture: ProfileRepositoryFixture; @@ -63,7 +64,7 @@ describe("Organizations Event Types Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); diff --git a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts index f11c54f112..ff729aef9c 100644 --- a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.e2e-spec.ts @@ -11,6 +11,7 @@ import { Test } from "@nestjs/testing"; import { User } from "@prisma/client"; import * as request from "supertest"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { withApiAuth } from "test/utils/withApiAuth"; @@ -24,7 +25,7 @@ describe("Organizations Memberships Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let membershipsRepositoryFixture: MembershipRepositoryFixture; let org: Team; @@ -51,7 +52,7 @@ describe("Organizations Memberships Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); user = await userRepositoryFixture.create({ @@ -210,7 +211,7 @@ describe("Organizations Memberships Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let membershipsRepositoryFixture: MembershipRepositoryFixture; let org: Team; @@ -228,7 +229,7 @@ describe("Organizations Memberships Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); user = await userRepositoryFixture.create({ diff --git a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts index 5de085f4fc..ec28563265 100644 --- a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts @@ -5,6 +5,7 @@ import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input"; @@ -41,7 +42,7 @@ import { Membership } from "@calcom/prisma/client"; path: "/v2/organizations/:orgId/memberships", version: API_VERSIONS_VALUES, }) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) @DocsTags("Organizations Memberships") export class OrganizationsMembershipsController { constructor(private organizationsMembershipService: OrganizationsMembershipService) {} diff --git a/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts index 24a4b91268..54ffcdcad1 100644 --- a/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.controller.ts @@ -4,6 +4,7 @@ import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.de import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard"; @@ -40,7 +41,7 @@ import { SkipTakePagination } from "@calcom/platform-types"; path: "/v2/organizations/:orgId", version: API_VERSIONS_VALUES, }) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) @DocsTags("Organizations Schedules") export class OrganizationsSchedulesController { constructor( diff --git a/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.e2e-spec.ts index 9b972baaa2..ccddb22547 100644 --- a/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/schedules/organizations-schedules.e2e-spec.ts @@ -8,6 +8,7 @@ import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; import * as request from "supertest"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; @@ -31,7 +32,7 @@ describe("Organizations Schedules Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let membershipFixtures: MembershipRepositoryFixture; const userEmail = "mr-robot@schedules-api.com"; @@ -48,7 +49,7 @@ describe("Organizations Schedules Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); membershipFixtures = new MembershipRepositoryFixture(moduleRef); org = await organizationsRepositoryFixture.create({ @@ -110,7 +111,7 @@ describe("Organizations Schedules Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let membershipFixtures: MembershipRepositoryFixture; let profileRepositoryFixture: ProfileRepositoryFixture; @@ -146,7 +147,7 @@ describe("Organizations Schedules Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); membershipFixtures = new MembershipRepositoryFixture(moduleRef); profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts index 01c774fb6a..769a3ca3fc 100644 --- a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.e2e-spec.ts @@ -11,6 +11,7 @@ import { Test } from "@nestjs/testing"; import { User } from "@prisma/client"; import * as request from "supertest"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; @@ -25,7 +26,7 @@ describe("Organizations Teams Memberships Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let teamsRepositoryFixture: TeamRepositoryFixture; let profileRepositoryFixture: ProfileRepositoryFixture; @@ -59,7 +60,7 @@ describe("Organizations Teams Memberships Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); @@ -335,7 +336,7 @@ describe("Organizations Teams Memberships Endpoints", () => { await userRepositoryFixture.deleteByEmail(nonOrgUser.email); await userRepositoryFixture.deleteByEmail(user2.email); await organizationsRepositoryFixture.delete(org.id); - await organizationsRepositoryFixture.delete(nonOrgTeam.id); + await teamsRepositoryFixture.delete(nonOrgTeam.id); await app.close(); }); }); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts index ebc5142b46..8c6b1d04cb 100644 --- a/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller.ts @@ -3,6 +3,7 @@ import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.de import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; @@ -40,7 +41,7 @@ import { SkipTakePagination } from "@calcom/platform-types"; path: "/v2/organizations/:orgId/teams/:teamId/memberships", version: API_VERSIONS_VALUES, }) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard) @DocsTags("Organizations Teams") export class OrganizationsTeamsMembershipsController { constructor( diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.e2e-spec.ts index 0a487bc7f3..bf44a3a79d 100644 --- a/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.e2e-spec.ts @@ -12,6 +12,7 @@ import { User } from "@prisma/client"; import * as request from "supertest"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { withApiAuth } from "test/utils/withApiAuth"; @@ -25,6 +26,7 @@ describe("Organizations Team Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let teamsRepositoryFixture: TeamRepositoryFixture; let membershipsRepositoryFixture: MembershipRepositoryFixture; @@ -46,6 +48,7 @@ describe("Organizations Team Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); @@ -54,7 +57,7 @@ describe("Organizations Team Endpoints", () => { username: userEmail, }); - org = await teamsRepositoryFixture.create({ + org = await organizationsRepositoryFixture.create({ name: "Test Organization", isOrganization: true, }); @@ -239,6 +242,7 @@ describe("Organizations Team Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let teamsRepositoryFixture: TeamRepositoryFixture; let membershipsRepositoryFixture: MembershipRepositoryFixture; @@ -258,6 +262,7 @@ describe("Organizations Team Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); @@ -266,7 +271,7 @@ describe("Organizations Team Endpoints", () => { username: userEmail, }); - org = await teamsRepositoryFixture.create({ + org = await organizationsRepositoryFixture.create({ name: "Test Organization", isOrganization: true, }); @@ -351,7 +356,7 @@ describe("Organizations Team Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let teamsRepositoryFixture: TeamRepositoryFixture; let membershipsRepositoryFixture: MembershipRepositoryFixture; @@ -371,7 +376,7 @@ describe("Organizations Team Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); @@ -478,6 +483,8 @@ describe("Organizations Team Endpoints", () => { let userRepositoryFixture: UserRepositoryFixture; let teamsRepositoryFixture: TeamRepositoryFixture; + let orgRepositoryFixture: OrganizationRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; @@ -502,13 +509,13 @@ describe("Organizations Team Endpoints", () => { teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); - + orgRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); user = await userRepositoryFixture.create({ email: userEmail, username: userEmail, }); - org = await teamsRepositoryFixture.create({ + org = await orgRepositoryFixture.create({ name: "Platform Test Organization", isOrganization: true, }); diff --git a/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts index 87fc4a7049..beca0de535 100644 --- a/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/teams/organizations-teams.controller.ts @@ -5,6 +5,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard"; @@ -43,7 +44,7 @@ import { Team } from "@calcom/prisma/client"; path: "/v2/organizations/:orgId/teams", version: API_VERSIONS_VALUES, }) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) @DocsTags("Organizations Teams") export class OrganizationsTeamsController { constructor(private organizationsTeamsService: OrganizationsTeamsService) {} diff --git a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts index 0d094507d3..6e726275f2 100644 --- a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.controller.ts @@ -5,6 +5,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard"; @@ -41,7 +42,7 @@ import { Team } from "@calcom/prisma/client"; version: API_VERSIONS_VALUES, }) @UseInterceptors(ClassSerializerInterceptor) -@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) @UseGuards(IsOrgGuard) @DocsTags("Organizations Users") export class OrganizationsUsersController { diff --git a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts index 0a977de9c3..3f1205570a 100644 --- a/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts +++ b/apps/api/v2/src/modules/organizations/controllers/users/organizations-users.e2e-spec.ts @@ -10,6 +10,7 @@ import { Test } from "@nestjs/testing"; import * as request from "supertest"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; @@ -23,7 +24,7 @@ describe("Organizations Users Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let membershipFixtures: MembershipRepositoryFixture; let profileRepositoryFixture: ProfileRepositoryFixture; @@ -40,7 +41,7 @@ describe("Organizations Users Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); membershipFixtures = new MembershipRepositoryFixture(moduleRef); profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); @@ -113,7 +114,7 @@ describe("Organizations Users Endpoints", () => { let app: INestApplication; let profileRepositoryFixture: ProfileRepositoryFixture; let userRepositoryFixture: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let membershipFixtures: MembershipRepositoryFixture; const userEmail = "admin1@org.com"; @@ -148,7 +149,7 @@ describe("Organizations Users Endpoints", () => { userRepositoryFixture = new UserRepositoryFixture(moduleRef); profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); membershipFixtures = new MembershipRepositoryFixture(moduleRef); org = await organizationsRepositoryFixture.create({ @@ -349,7 +350,8 @@ describe("Organizations Users Endpoints", () => { let app: INestApplication; let userRepositoryFixture: UserRepositoryFixture; - let organizationsRepositoryFixture: TeamRepositoryFixture; + let teamsRepositoryFixture: TeamRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; let eventTypesRepositoryFixture: EventTypesRepositoryFixture; let membershipFixtures: MembershipRepositoryFixture; let profileRepositoryFixture: ProfileRepositoryFixture; @@ -370,7 +372,9 @@ describe("Organizations Users Endpoints", () => { ).compile(); userRepositoryFixture = new UserRepositoryFixture(moduleRef); - organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); membershipFixtures = new MembershipRepositoryFixture(moduleRef); profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef); @@ -380,7 +384,7 @@ describe("Organizations Users Endpoints", () => { isOrganization: true, }); - team = await organizationsRepositoryFixture.create({ + team = await teamsRepositoryFixture.create({ name: "Test org 4 team", isOrganization: false, parent: { connect: { id: org.id } }, diff --git a/apps/api/v2/src/modules/organizations/organizations.repository.ts b/apps/api/v2/src/modules/organizations/organizations.repository.ts index 25f89e4b70..b5b8af6273 100644 --- a/apps/api/v2/src/modules/organizations/organizations.repository.ts +++ b/apps/api/v2/src/modules/organizations/organizations.repository.ts @@ -102,4 +102,15 @@ export class OrganizationsRepository { }, }); } + + async fetchOrgAdminApiStatus(organizationId: number) { + return this.dbRead.prisma.organizationSettings.findUnique({ + where: { + organizationId, + }, + select: { + isAdminAPIEnabled: true, + }, + }); + } } diff --git a/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts new file mode 100644 index 0000000000..50a528a007 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/organization.repository.fixture.ts @@ -0,0 +1,44 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma, Team } from "@prisma/client"; + +export class OrganizationRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async get(teamId: Team["id"]) { + return this.primaReadClient.team.findFirst({ where: { id: teamId } }); + } + + async create(data: Prisma.TeamCreateInput) { + return await this.prismaWriteClient.$transaction(async (prisma) => { + const team = await prisma.team.create({ + data, + }); + + await prisma.organizationSettings.create({ + data: { + organizationId: team.id, + isAdminAPIEnabled: true, + orgAutoAcceptEmail: "cal.com", + }, + }); + return team; + }); + } + + async delete(teamId: Team["id"]) { + return await this.prismaWriteClient.$transaction(async (prisma) => { + await prisma.organizationSettings.delete({ + where: { organizationId: teamId }, + }); + return prisma.team.delete({ where: { id: teamId } }); + }); + } +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e8fbf8d97f..394c153528 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2507,6 +2507,8 @@ "event_expired": "This event is expired", "skip_contact_creation": "Skip creating contacts if they do not exist in {{appName}} ", "skip_writing_to_calendar_note": "If your ICS link is read-only (e.g., Proton Calendar), check the box above to avoid errors. You'll also need to manually update your calendar for changes.", + "grant_admin_api": "Grant Admin API Access", + "revoke_admin_api": "Revoke Admin API Access", "apple_connect_atom_label": "Connect Apple Calendar", "apple_connect_atom_already_connected_label": "Connected Apple Calendar", "apple_connect_atom_loading_label": "Checking Apple Calendar", diff --git a/packages/features/ee/organizations/pages/settings/admin/AdminOrgPage.tsx b/packages/features/ee/organizations/pages/settings/admin/AdminOrgPage.tsx index 465242618a..0e42e9230b 100644 --- a/packages/features/ee/organizations/pages/settings/admin/AdminOrgPage.tsx +++ b/packages/features/ee/organizations/pages/settings/admin/AdminOrgPage.tsx @@ -81,6 +81,7 @@ function AdminOrgTable() { {t("reviewed")} {t("dns_configured")} {t("published")} + {t("admin_api")} {t("edit")} @@ -129,6 +130,15 @@ function AdminOrgTable() { )} + +
+ {!org.organizationSettings?.isAdminAPIEnabled ? ( + {t("disabled")} + ) : ( + {t("enabled")} + )} +
+
{ + updateMutation.mutate({ + id: org.id, + organizationSettings: { + isAdminAPIEnabled: !org.organizationSettings?.isAdminAPIEnabled, + }, + }); + }, + icon: "terminal" as const, + }, { id: "delete", label: t("delete"), diff --git a/packages/prisma/migrations/20240722105401_add_is_admin_api_enabled_organization_setting/migration.sql b/packages/prisma/migrations/20240722105401_add_is_admin_api_enabled_organization_setting/migration.sql new file mode 100644 index 0000000000..35b33ed06c --- /dev/null +++ b/packages/prisma/migrations/20240722105401_add_is_admin_api_enabled_organization_setting/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "OrganizationSettings" ADD COLUMN "isAdminAPIEnabled" BOOLEAN NOT NULL DEFAULT false; + +-- User written Query: Update OrganizationSettings table to mark all existing organizations to have AdminAPI Enabled to not lose functionality. +UPDATE "OrganizationSettings" SET "isAdminAPIEnabled" = true; \ No newline at end of file diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index f7ece8c1da..9c2ac966bb 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -431,6 +431,7 @@ model OrganizationSettings { // It is used to allow super sensitive operations like 'impersonation of Org members by Org admin' isAdminReviewed Boolean @default(false) dSyncData DSyncData? + isAdminAPIEnabled Boolean @default(false) } enum MembershipRole { diff --git a/packages/prisma/seed.ts b/packages/prisma/seed.ts index 2d7ae4a40b..76642f8519 100644 --- a/packages/prisma/seed.ts +++ b/packages/prisma/seed.ts @@ -1067,6 +1067,7 @@ async function main() { organizationSettings: { isOrganizationVerified: true, orgAutoAcceptEmail: "acme.com", + isAdminAPIEnabled: true, }, }, members: [ diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 2c21c0d55a..c356897b51 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -359,6 +359,7 @@ export const orgSettingsSchema = z isOrganizationConfigured: z.boolean().optional(), isAdminReviewed: z.boolean().optional(), orgAutoAcceptEmail: z.string().optional(), + isAdminAPIEnabled: z.boolean().optional(), }) .nullable(); export type userMetadataType = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/adminUpdate.handler.ts b/packages/trpc/server/routers/viewer/organizations/adminUpdate.handler.ts index 7b18bb1dbb..0291130579 100644 --- a/packages/trpc/server/routers/viewer/organizations/adminUpdate.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/adminUpdate.handler.ts @@ -74,6 +74,9 @@ export const adminUpdateHandler = async ({ input }: AdminUpdateOptions) => { isAdminReviewed: organizationSettings?.isAdminReviewed, orgAutoAcceptEmail: organizationSettings?.orgAutoAcceptEmail || existingOrg.organizationSettings?.orgAutoAcceptEmail, + isAdminAPIEnabled: + !!organizationSettings?.isAdminAPIEnabled ?? + !!existingOrg.organizationSettings?.isAdminAPIEnabled, }, }); }