fix: access service class for editLocationHandler (#25315)
* fix access service class * Move out of trpc
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { CredentialAccessService } from "./CredentialAccessService";
|
||||
|
||||
vi.mock("@calcom/prisma", () => {
|
||||
return {
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/credentials/repositories/CredentialRepository", () => {
|
||||
return {
|
||||
CredentialRepository: {
|
||||
findFirstByIdWithKeyAndUser: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/users/repositories/UserRepository", () => {
|
||||
return {
|
||||
UserRepository: vi.fn().mockImplementation(() => ({
|
||||
getUserOrganizationAndTeams: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe("CredentialAccessService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should allow access when credential belongs to logged-in user", async () => {
|
||||
const credentialId = 1;
|
||||
const loggedInUserId = 100;
|
||||
const bookingOwnerId = 200;
|
||||
|
||||
vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({
|
||||
id: credentialId,
|
||||
userId: loggedInUserId,
|
||||
teamId: null,
|
||||
type: "zoom_video",
|
||||
appId: "zoom",
|
||||
key: {},
|
||||
invalid: false,
|
||||
user: { email: "user@example.com" },
|
||||
delegatedTo: null,
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
} as any);
|
||||
|
||||
const service = new CredentialAccessService();
|
||||
await expect(
|
||||
service.ensureAccessible({
|
||||
credentialId,
|
||||
loggedInUserId,
|
||||
bookingOwnerId,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test("should allow access when credential belongs to booking owner", async () => {
|
||||
const credentialId = 1;
|
||||
const loggedInUserId = 100;
|
||||
const bookingOwnerId = 200;
|
||||
|
||||
vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({
|
||||
id: credentialId,
|
||||
userId: bookingOwnerId,
|
||||
teamId: null,
|
||||
type: "zoom_video",
|
||||
appId: "zoom",
|
||||
key: {},
|
||||
invalid: false,
|
||||
user: { email: "owner@example.com" },
|
||||
delegatedTo: null,
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
} as any);
|
||||
|
||||
const service = new CredentialAccessService();
|
||||
await expect(
|
||||
service.ensureAccessible({
|
||||
credentialId,
|
||||
loggedInUserId,
|
||||
bookingOwnerId,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test("should allow access when credential belongs to team the logged-in user belongs to", async () => {
|
||||
const credentialId = 1;
|
||||
const loggedInUserId = 100;
|
||||
const bookingOwnerId = 200;
|
||||
const teamId = 50;
|
||||
|
||||
vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({
|
||||
id: credentialId,
|
||||
userId: null,
|
||||
teamId: teamId,
|
||||
type: "zoom_video",
|
||||
appId: "zoom",
|
||||
key: {},
|
||||
invalid: false,
|
||||
user: null,
|
||||
delegatedTo: null,
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
} as any);
|
||||
|
||||
const mockUserRepo = {
|
||||
getUserOrganizationAndTeams: vi.fn().mockResolvedValue({
|
||||
organizationId: null,
|
||||
teams: [{ teamId: teamId }],
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any);
|
||||
|
||||
const service = new CredentialAccessService();
|
||||
await expect(
|
||||
service.ensureAccessible({
|
||||
credentialId,
|
||||
loggedInUserId,
|
||||
bookingOwnerId,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test("should allow access when credential belongs to team the booking owner belongs to", async () => {
|
||||
const credentialId = 1;
|
||||
const loggedInUserId = 100;
|
||||
const bookingOwnerId = 200;
|
||||
const teamId = 50;
|
||||
|
||||
vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({
|
||||
id: credentialId,
|
||||
userId: null,
|
||||
teamId: teamId,
|
||||
type: "zoom_video",
|
||||
appId: "zoom",
|
||||
key: {},
|
||||
invalid: false,
|
||||
user: null,
|
||||
delegatedTo: null,
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
} as any);
|
||||
|
||||
const mockUserRepo = {
|
||||
getUserOrganizationAndTeams: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
organizationId: null,
|
||||
teams: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
organizationId: null,
|
||||
teams: [{ teamId: teamId }],
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any);
|
||||
|
||||
const service = new CredentialAccessService();
|
||||
await expect(
|
||||
service.ensureAccessible({
|
||||
credentialId,
|
||||
loggedInUserId,
|
||||
bookingOwnerId,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test("should allow access when credential belongs to organization the logged-in user belongs to", async () => {
|
||||
const credentialId = 1;
|
||||
const loggedInUserId = 100;
|
||||
const bookingOwnerId = 200;
|
||||
const orgId = 50;
|
||||
|
||||
vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({
|
||||
id: credentialId,
|
||||
userId: null,
|
||||
teamId: orgId,
|
||||
type: "zoom_video",
|
||||
appId: "zoom",
|
||||
key: {},
|
||||
invalid: false,
|
||||
user: null,
|
||||
delegatedTo: null,
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
} as any);
|
||||
|
||||
const mockUserRepo = {
|
||||
getUserOrganizationAndTeams: vi.fn().mockResolvedValue({
|
||||
organizationId: orgId,
|
||||
teams: [],
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any);
|
||||
|
||||
const service = new CredentialAccessService();
|
||||
await expect(
|
||||
service.ensureAccessible({
|
||||
credentialId,
|
||||
loggedInUserId,
|
||||
bookingOwnerId,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test("should throw NOT_FOUND when credential does not exist", async () => {
|
||||
const credentialId = 1;
|
||||
const loggedInUserId = 100;
|
||||
const bookingOwnerId = 200;
|
||||
|
||||
vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue(null);
|
||||
|
||||
const service = new CredentialAccessService();
|
||||
const error = await service
|
||||
.ensureAccessible({
|
||||
credentialId,
|
||||
loggedInUserId,
|
||||
bookingOwnerId,
|
||||
})
|
||||
.catch((e) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect(error.statusCode).toBe(404);
|
||||
expect(error.message).toBe("Credential not found");
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN when credential is not accessible", async () => {
|
||||
const credentialId = 1;
|
||||
const loggedInUserId = 100;
|
||||
const bookingOwnerId = 200;
|
||||
const otherUserId = 300;
|
||||
|
||||
vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({
|
||||
id: credentialId,
|
||||
userId: otherUserId,
|
||||
teamId: null,
|
||||
type: "zoom_video",
|
||||
appId: "zoom",
|
||||
key: {},
|
||||
invalid: false,
|
||||
user: { email: "other@example.com" },
|
||||
delegatedTo: null,
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
} as any);
|
||||
|
||||
const mockUserRepo = {
|
||||
getUserOrganizationAndTeams: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
organizationId: null,
|
||||
teams: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
organizationId: null,
|
||||
teams: [],
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(UserRepository).mockImplementation(() => mockUserRepo as any);
|
||||
|
||||
const service = new CredentialAccessService();
|
||||
const error = await service
|
||||
.ensureAccessible({
|
||||
credentialId,
|
||||
loggedInUserId,
|
||||
bookingOwnerId,
|
||||
})
|
||||
.catch((e) => e);
|
||||
|
||||
expect(error).toBeInstanceOf(HttpError);
|
||||
expect(error.statusCode).toBe(403);
|
||||
expect(error.message).toBe("You do not have access to this credential");
|
||||
});
|
||||
|
||||
test("should handle null bookingOwnerId", async () => {
|
||||
const credentialId = 1;
|
||||
const loggedInUserId = 100;
|
||||
const bookingOwnerId = null;
|
||||
|
||||
vi.mocked(CredentialRepository.findFirstByIdWithKeyAndUser).mockResolvedValue({
|
||||
id: credentialId,
|
||||
userId: loggedInUserId,
|
||||
teamId: null,
|
||||
type: "zoom_video",
|
||||
appId: "zoom",
|
||||
key: {},
|
||||
invalid: false,
|
||||
user: { email: "user@example.com" },
|
||||
delegatedTo: null,
|
||||
delegatedToId: null,
|
||||
delegationCredentialId: null,
|
||||
} as any);
|
||||
|
||||
const service = new CredentialAccessService();
|
||||
await expect(
|
||||
service.ensureAccessible({
|
||||
credentialId,
|
||||
loggedInUserId,
|
||||
bookingOwnerId,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
type CredentialAccessInput = {
|
||||
credentialId: number;
|
||||
loggedInUserId: number;
|
||||
bookingOwnerId: number | null;
|
||||
};
|
||||
|
||||
type UserTeamsData = {
|
||||
organizationId: number | null;
|
||||
teams: Array<{ teamId: number }>;
|
||||
} | null;
|
||||
|
||||
export class CredentialAccessService {
|
||||
private readonly userRepository: UserRepository;
|
||||
|
||||
constructor(private readonly prismaClient: PrismaClient = prisma) {
|
||||
this.userRepository = new UserRepository(this.prismaClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a credential is accessible by the logged-in user or booking owner.
|
||||
*/
|
||||
async ensureAccessible(input: CredentialAccessInput): Promise<void> {
|
||||
const credential = await this.checkCredentialExists(input.credentialId);
|
||||
|
||||
if (this.checkUserOwnership(credential, input.loggedInUserId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.checkBookingOwnerOwnership(credential, input.bookingOwnerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.checkTeamAccess(credential, input.loggedInUserId, input.bookingOwnerId);
|
||||
}
|
||||
|
||||
private async checkCredentialExists(credentialId: number) {
|
||||
const credential = await CredentialRepository.findFirstByIdWithKeyAndUser({
|
||||
id: credentialId,
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: "Credential not found",
|
||||
});
|
||||
}
|
||||
|
||||
return credential;
|
||||
}
|
||||
|
||||
private checkUserOwnership(
|
||||
credential: Awaited<ReturnType<typeof CredentialRepository.findFirstByIdWithKeyAndUser>>,
|
||||
loggedInUserId: number
|
||||
): boolean {
|
||||
return credential?.userId === loggedInUserId;
|
||||
}
|
||||
|
||||
private checkBookingOwnerOwnership(
|
||||
credential: Awaited<ReturnType<typeof CredentialRepository.findFirstByIdWithKeyAndUser>>,
|
||||
bookingOwnerId: number | null
|
||||
): boolean {
|
||||
return bookingOwnerId !== null && credential?.userId === bookingOwnerId;
|
||||
}
|
||||
|
||||
private async checkTeamAccess(
|
||||
credential: NonNullable<Awaited<ReturnType<typeof CredentialRepository.findFirstByIdWithKeyAndUser>>>,
|
||||
loggedInUserId: number,
|
||||
bookingOwnerId: number | null
|
||||
): Promise<void> {
|
||||
if (!credential.teamId) {
|
||||
this.throwForbiddenError();
|
||||
}
|
||||
|
||||
const [loggedInUserTeams, bookingOwnerTeams] = await this.getUserTeamsData(
|
||||
loggedInUserId,
|
||||
bookingOwnerId
|
||||
);
|
||||
|
||||
if (this.hasTeamAccess(credential.teamId, loggedInUserTeams)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasTeamAccess(credential.teamId, bookingOwnerTeams)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.throwForbiddenError();
|
||||
}
|
||||
|
||||
private async getUserTeamsData(
|
||||
loggedInUserId: number,
|
||||
bookingOwnerId: number | null
|
||||
): Promise<[UserTeamsData, UserTeamsData]> {
|
||||
return Promise.all([
|
||||
this.userRepository.getUserOrganizationAndTeams({ userId: loggedInUserId }),
|
||||
bookingOwnerId
|
||||
? this.userRepository.getUserOrganizationAndTeams({ userId: bookingOwnerId })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
}
|
||||
|
||||
private hasTeamAccess(teamId: number, userTeams: UserTeamsData): boolean {
|
||||
if (!userTeams) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const teamIds = this.getTeamIdsForUser(userTeams);
|
||||
return teamIds.includes(teamId);
|
||||
}
|
||||
|
||||
private getTeamIdsForUser(userTeams: NonNullable<UserTeamsData>): number[] {
|
||||
return [
|
||||
...(userTeams.organizationId ? [userTeams.organizationId] : []),
|
||||
...(userTeams.teams || []).map((t) => t.teamId),
|
||||
];
|
||||
}
|
||||
|
||||
private throwForbiddenError(): never {
|
||||
throw new HttpError({
|
||||
statusCode: 403,
|
||||
message: "You do not have access to this credential",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { sendLocationChangeEmailsAndSMS } from "@calcom/emails/email-manager";
|
||||
import EventManager from "@calcom/features/bookings/lib/EventManager";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
|
||||
import { CredentialAccessService } from "@calcom/features/credentials/services/CredentialAccessService";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
||||
import { buildCalEventFromBooking } from "@calcom/lib/buildCalEventFromBooking";
|
||||
@@ -125,15 +126,26 @@ async function updateBookingLocationInDb({
|
||||
async function getAllCredentialsIncludeServiceAccountKey({
|
||||
user,
|
||||
conferenceCredentialId,
|
||||
bookingOwnerId,
|
||||
}: {
|
||||
user: { id: number; email: string };
|
||||
conferenceCredentialId: number | null;
|
||||
bookingOwnerId: number | null;
|
||||
}) {
|
||||
const credentials = await getUsersCredentialsIncludeServiceAccountKey(user);
|
||||
|
||||
let conferenceCredential;
|
||||
|
||||
if (conferenceCredentialId) {
|
||||
// Validate that the credential is accessible before fetching it
|
||||
const credentialAccessService = new CredentialAccessService();
|
||||
await credentialAccessService.ensureAccessible({
|
||||
credentialId: conferenceCredentialId,
|
||||
loggedInUserId: user.id,
|
||||
bookingOwnerId,
|
||||
});
|
||||
|
||||
// Now fetch the credential with the key
|
||||
conferenceCredential = await CredentialRepository.findFirstByIdWithKeyAndUser({
|
||||
id: conferenceCredentialId,
|
||||
});
|
||||
@@ -266,7 +278,11 @@ export async function editLocationHandler({ ctx, input }: EditLocationOptions) {
|
||||
|
||||
const eventManager = new EventManager({
|
||||
...ctx.user,
|
||||
credentials: await getAllCredentialsIncludeServiceAccountKey({ user: ctx.user, conferenceCredentialId }),
|
||||
credentials: await getAllCredentialsIncludeServiceAccountKey({
|
||||
user: ctx.user,
|
||||
conferenceCredentialId,
|
||||
bookingOwnerId: booking.userId,
|
||||
}),
|
||||
});
|
||||
|
||||
const updatedResult = await updateLocationInConnectedAppForBooking({
|
||||
|
||||
Reference in New Issue
Block a user