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
This commit is contained in:
Lauris Skraucis
2024-03-25 12:21:41 +01:00
committed by GitHub
parent b520238d33
commit 070548555c
10 changed files with 239 additions and 115 deletions
@@ -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<boolean> {
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(", ")}.`);
}
}
}
@@ -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();
});
});
@@ -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,
@@ -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 {}
@@ -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,
},
});
}
}
@@ -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;
}
}
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "isPlatform" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -378,6 +378,7 @@ model Team {
orgProfiles Profile[]
pendingPayment Boolean @default(false)
dsyncTeamGroupMapping DSyncTeamGroupMapping[]
isPlatform Boolean @default(false)
platformOAuthClient PlatformOAuthClient[]
@@unique([slug, parentId])
@@ -36,7 +36,7 @@ const getIPAddress = async (url: string): Promise<string> => {
};
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;
@@ -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<typeof ZCreateInputSchema>;