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:
+42
-13
@@ -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(", ")}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
-2
@@ -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;
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user