fix: access service class for editLocationHandler (#25315)

* fix access service class

* Move out of trpc
This commit is contained in:
sean-brydon
2025-11-21 13:13:06 -03:00
committed by GitHub
parent 44311f51ba
commit c941192979
3 changed files with 467 additions and 1 deletions
@@ -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({