fix: event-type permissions (#24318)
* New permission function * Update permission check service to have fall backs Update event type create permission procedure to also have org heigharchy * Update get eventType handler to use new procedure * Type check * Type fix * Update packages/features/pbac/lib/event-type-permissions.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix type errorr --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
coderabbitai[bot]
parent
8277ce934b
commit
5aa24c8472
@@ -8,10 +8,7 @@ import { z } from "zod";
|
||||
|
||||
import { EventTypeWebWrapper } from "@calcom/atoms/event-types/wrappers/EventTypeWebWrapper";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
|
||||
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { getEventTypePermissions } from "@calcom/features/pbac/lib/event-type-permissions";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
@@ -44,95 +41,6 @@ const getCachedEventType = unstable_cache(
|
||||
{ revalidate: 3600 } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const getEventPermissions = async (userId: number, teamId: number | null) => {
|
||||
// Personal event - has all perms
|
||||
if (!teamId)
|
||||
return {
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
};
|
||||
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) throw new Error("Membership not found");
|
||||
|
||||
const [eventTypePermissions, workflowPermissions] = await Promise.all([
|
||||
getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.EventType,
|
||||
userRole: membership.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
}),
|
||||
getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.Workflow,
|
||||
userRole: membership.role,
|
||||
fallbackRoles: {
|
||||
read: {
|
||||
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
update: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
delete: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
create: {
|
||||
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
eventTypes: {
|
||||
canRead: eventTypePermissions.canRead,
|
||||
canCreate: eventTypePermissions.canCreate,
|
||||
canUpdate: eventTypePermissions.canEdit,
|
||||
canDelete: eventTypePermissions.canDelete,
|
||||
},
|
||||
workflows: {
|
||||
canRead: workflowPermissions.canRead,
|
||||
canCreate: workflowPermissions.canCreate,
|
||||
canUpdate: workflowPermissions.canEdit,
|
||||
canDelete: workflowPermissions.canDelete,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ServerPage = async ({ params }: PageProps) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
@@ -153,7 +61,7 @@ const ServerPage = async ({ params }: PageProps) => {
|
||||
}
|
||||
|
||||
// Fetch permissions for the event type's team
|
||||
const permissions = await getEventPermissions(session.user.id, data.eventType.teamId);
|
||||
const permissions = await getEventTypePermissions(session.user.id, data.eventType.teamId);
|
||||
|
||||
return <EventTypeWebWrapper data={data} id={eventTypeId} permissions={permissions} />;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
import { prisma } from "@calcom/prisma/__mocks__/prisma";
|
||||
|
||||
import { vi, type Mock, describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { Resource } from "../../domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "../../services/permission-check.service";
|
||||
import { getEventTypePermissions } from "../event-type-permissions";
|
||||
|
||||
vi.mock("@calcom/features/flags/features.repository");
|
||||
vi.mock("../../services/permission-check.service");
|
||||
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
prisma,
|
||||
}));
|
||||
|
||||
describe("getEventTypePermissions", () => {
|
||||
let mockFeaturesRepository: {
|
||||
checkIfTeamHasFeature: Mock;
|
||||
};
|
||||
let mockPermissionCheckService: {
|
||||
getResourcePermissions: Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockFeaturesRepository = {
|
||||
checkIfTeamHasFeature: vi.fn(),
|
||||
};
|
||||
|
||||
mockPermissionCheckService = {
|
||||
getResourcePermissions: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(FeaturesRepository).mockImplementation(() => mockFeaturesRepository as any);
|
||||
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
|
||||
|
||||
prisma.membership.findFirst = vi.fn();
|
||||
prisma.team.findUnique = vi.fn();
|
||||
});
|
||||
|
||||
describe("personal event types", () => {
|
||||
it("should return full permissions for personal events (teamId is null)", async () => {
|
||||
const result = await getEventTypePermissions(1, null);
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockFeaturesRepository.checkIfTeamHasFeature).not.toHaveBeenCalled();
|
||||
expect(mockPermissionCheckService.getResourcePermissions).not.toHaveBeenCalled();
|
||||
expect(prisma.membership.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("team event types with PBAC enabled", () => {
|
||||
beforeEach(() => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should use PBAC permissions when enabled", async () => {
|
||||
mockPermissionCheckService.getResourcePermissions
|
||||
.mockResolvedValueOnce(["eventType.read", "eventType.create", "eventType.update"])
|
||||
.mockResolvedValueOnce(["workflow.read", "workflow.update"]);
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(2, "pbac");
|
||||
expect(mockPermissionCheckService.getResourcePermissions).toHaveBeenCalledTimes(2);
|
||||
expect(mockPermissionCheckService.getResourcePermissions).toHaveBeenNthCalledWith(1, {
|
||||
userId: 1,
|
||||
teamId: 2,
|
||||
resource: Resource.EventType,
|
||||
});
|
||||
expect(mockPermissionCheckService.getResourcePermissions).toHaveBeenNthCalledWith(2, {
|
||||
userId: 1,
|
||||
teamId: 2,
|
||||
resource: Resource.Workflow,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: false,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: false,
|
||||
canUpdate: true,
|
||||
canDelete: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(prisma.membership.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should map canEdit to canUpdate correctly", async () => {
|
||||
mockPermissionCheckService.getResourcePermissions
|
||||
.mockResolvedValueOnce(["eventType.read", "eventType.update"])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(result.eventTypes.canUpdate).toBe(true);
|
||||
expect(result.workflows.canUpdate).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle all permissions denied", async () => {
|
||||
mockPermissionCheckService.getResourcePermissions.mockResolvedValue([]);
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: false,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
},
|
||||
workflows: {
|
||||
canRead: false,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle all permissions granted", async () => {
|
||||
mockPermissionCheckService.getResourcePermissions.mockResolvedValue([
|
||||
"eventType.read",
|
||||
"eventType.create",
|
||||
"eventType.update",
|
||||
"eventType.delete",
|
||||
"workflow.read",
|
||||
"workflow.create",
|
||||
"workflow.update",
|
||||
"workflow.delete",
|
||||
]);
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("team event types with PBAC disabled (role-based fallback)", () => {
|
||||
beforeEach(() => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("team without parent org", () => {
|
||||
beforeEach(() => {
|
||||
(prisma.team.findUnique as Mock).mockResolvedValue({
|
||||
parentId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should grant full permissions to OWNER role", async () => {
|
||||
(prisma.membership.findFirst as Mock).mockResolvedValueOnce({
|
||||
role: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(2, "pbac");
|
||||
expect(prisma.team.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: 2,
|
||||
},
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should grant full permissions to ADMIN role", async () => {
|
||||
(prisma.membership.findFirst as Mock).mockResolvedValueOnce({
|
||||
role: MembershipRole.ADMIN,
|
||||
});
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should grant read-only permissions to MEMBER role", async () => {
|
||||
(prisma.membership.findFirst as Mock).mockResolvedValueOnce({
|
||||
role: MembershipRole.MEMBER,
|
||||
});
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error when membership not found", async () => {
|
||||
(prisma.membership.findFirst as Mock).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(getEventTypePermissions(1, 2)).rejects.toThrow("Membership not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("team with parent org - org role takes precedence", () => {
|
||||
beforeEach(() => {
|
||||
(prisma.team.findUnique as Mock).mockResolvedValue({
|
||||
parentId: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use org ADMIN role when user is not a team member", async () => {
|
||||
(prisma.membership.findFirst as Mock)
|
||||
.mockResolvedValueOnce(null) // No team membership
|
||||
.mockResolvedValueOnce({
|
||||
// Org membership
|
||||
role: MembershipRole.ADMIN,
|
||||
});
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(prisma.membership.findFirst).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.membership.findFirst).toHaveBeenNthCalledWith(1, {
|
||||
where: { userId: 1, teamId: 2 },
|
||||
select: { role: true },
|
||||
});
|
||||
expect(prisma.membership.findFirst).toHaveBeenNthCalledWith(2, {
|
||||
where: { userId: 1, teamId: 100 },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use org ADMIN role when team role is MEMBER", async () => {
|
||||
(prisma.membership.findFirst as Mock)
|
||||
.mockResolvedValueOnce({
|
||||
role: MembershipRole.MEMBER,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
role: MembershipRole.ADMIN,
|
||||
});
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use org OWNER role when team role is ADMIN", async () => {
|
||||
(prisma.membership.findFirst as Mock)
|
||||
.mockResolvedValueOnce({
|
||||
role: MembershipRole.ADMIN,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
role: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use team ADMIN role when org role is MEMBER", async () => {
|
||||
(prisma.membership.findFirst as Mock)
|
||||
.mockResolvedValueOnce({
|
||||
role: MembershipRole.ADMIN,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
role: MembershipRole.MEMBER,
|
||||
});
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use team OWNER role when org role is ADMIN", async () => {
|
||||
(prisma.membership.findFirst as Mock)
|
||||
.mockResolvedValueOnce({
|
||||
role: MembershipRole.OWNER,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
role: MembershipRole.ADMIN,
|
||||
});
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use org MEMBER role when user has no team membership", async () => {
|
||||
(prisma.membership.findFirst as Mock)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
role: MembershipRole.MEMBER,
|
||||
});
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(result).toEqual({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: false,
|
||||
canUpdate: false,
|
||||
canDelete: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw error when no team or org membership found", async () => {
|
||||
(prisma.membership.findFirst as Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(getEventTypePermissions(1, 2)).rejects.toThrow("Membership not found");
|
||||
|
||||
expect(prisma.membership.findFirst).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should propagate errors from FeaturesRepository", async () => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockRejectedValue(
|
||||
new Error("Database connection failed")
|
||||
);
|
||||
|
||||
await expect(getEventTypePermissions(1, 2)).rejects.toThrow("Database connection failed");
|
||||
});
|
||||
|
||||
it("should propagate errors from PermissionCheckService", async () => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(true);
|
||||
mockPermissionCheckService.getResourcePermissions.mockRejectedValue(
|
||||
new Error("Permission service error")
|
||||
);
|
||||
|
||||
await expect(getEventTypePermissions(1, 2)).rejects.toThrow("Permission service error");
|
||||
});
|
||||
|
||||
it("should propagate errors from Prisma membership query", async () => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(false);
|
||||
(prisma.membership.findFirst as Mock).mockRejectedValue(new Error("Database query failed"));
|
||||
|
||||
await expect(getEventTypePermissions(1, 2)).rejects.toThrow("Database query failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration scenarios", () => {
|
||||
it("should handle switching from PBAC to role-based when PBAC check returns false", async () => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(false);
|
||||
(prisma.team.findUnique as Mock).mockResolvedValue({ parentId: null });
|
||||
(prisma.membership.findFirst as Mock).mockResolvedValue({
|
||||
role: MembershipRole.ADMIN,
|
||||
});
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(mockPermissionCheckService.getResourcePermissions).not.toHaveBeenCalled();
|
||||
expect(result.eventTypes.canCreate).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle different user and team combinations", async () => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(false);
|
||||
(prisma.team.findUnique as Mock).mockResolvedValue({ parentId: null });
|
||||
(prisma.membership.findFirst as Mock).mockResolvedValue({
|
||||
role: MembershipRole.MEMBER,
|
||||
});
|
||||
|
||||
const result1 = await getEventTypePermissions(100, 200);
|
||||
const result2 = await getEventTypePermissions(101, 201);
|
||||
|
||||
expect(prisma.membership.findFirst).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.membership.findFirst).toHaveBeenNthCalledWith(1, {
|
||||
where: { userId: 100, teamId: 200 },
|
||||
select: { role: true },
|
||||
});
|
||||
expect(prisma.membership.findFirst).toHaveBeenNthCalledWith(2, {
|
||||
where: { userId: 101, teamId: 201 },
|
||||
select: { role: true },
|
||||
});
|
||||
|
||||
expect(result1).toEqual(result2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission mapping consistency", () => {
|
||||
it("should ensure both eventTypes and workflows use same permission structure", async () => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(true);
|
||||
mockPermissionCheckService.getResourcePermissions.mockResolvedValue([
|
||||
"eventType.read",
|
||||
"eventType.update",
|
||||
]);
|
||||
|
||||
const result = await getEventTypePermissions(1, 2);
|
||||
|
||||
expect(Object.keys(result.eventTypes)).toEqual(["canRead", "canCreate", "canUpdate", "canDelete"]);
|
||||
expect(Object.keys(result.workflows)).toEqual(["canRead", "canCreate", "canUpdate", "canDelete"]);
|
||||
});
|
||||
|
||||
it("should maintain consistent permission types across PBAC and role-based modes", async () => {
|
||||
const pbacResult = await (async () => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(true);
|
||||
mockPermissionCheckService.getResourcePermissions.mockResolvedValue([
|
||||
"eventType.read",
|
||||
"eventType.create",
|
||||
"eventType.update",
|
||||
"eventType.delete",
|
||||
]);
|
||||
return await getEventTypePermissions(1, 2);
|
||||
})();
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Re-initialize mocks after clearing
|
||||
vi.mocked(FeaturesRepository).mockImplementation(() => mockFeaturesRepository as any);
|
||||
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
|
||||
prisma.membership.findFirst = vi.fn();
|
||||
prisma.team.findUnique = vi.fn();
|
||||
|
||||
const roleResult = await (async () => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValue(false);
|
||||
(prisma.team.findUnique as Mock).mockResolvedValue({ parentId: null });
|
||||
(prisma.membership.findFirst as Mock).mockResolvedValue({
|
||||
role: MembershipRole.OWNER,
|
||||
});
|
||||
return await getEventTypePermissions(1, 2);
|
||||
})();
|
||||
|
||||
expect(Object.keys(pbacResult.eventTypes)).toEqual(Object.keys(roleResult.eventTypes));
|
||||
expect(Object.keys(pbacResult.workflows)).toEqual(Object.keys(roleResult.workflows));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { Resource } from "../domain/types/permission-registry";
|
||||
import type { PermissionString } from "../domain/types/permission-registry";
|
||||
import { PermissionCheckService } from "../services/permission-check.service";
|
||||
|
||||
export interface ResourcePermissions {
|
||||
canRead: boolean;
|
||||
canCreate: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
export interface EventTypePermissions {
|
||||
eventTypes: ResourcePermissions;
|
||||
workflows: ResourcePermissions;
|
||||
}
|
||||
|
||||
const createFullPermissions = (): EventTypePermissions => ({
|
||||
eventTypes: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
workflows: {
|
||||
canRead: true,
|
||||
canCreate: true,
|
||||
canUpdate: true,
|
||||
canDelete: true,
|
||||
},
|
||||
});
|
||||
|
||||
const permissionsArrayToObject = (
|
||||
permissions: PermissionString[],
|
||||
resource: Resource
|
||||
): ResourcePermissions => {
|
||||
const resourcePrefix = `${resource}.`;
|
||||
|
||||
const has = (action: "read" | "create" | "update" | "delete") =>
|
||||
permissions.some(
|
||||
(p) =>
|
||||
p === `${resourcePrefix}${action}` || p === `${resourcePrefix}*` || p === `*.${action}` || p === "*.*"
|
||||
);
|
||||
|
||||
return {
|
||||
canRead: has("read"),
|
||||
canCreate: has("create"),
|
||||
canUpdate: has("update"),
|
||||
canDelete: has("delete"),
|
||||
};
|
||||
};
|
||||
|
||||
const getRoleBasedPermissions = (role: MembershipRole): ResourcePermissions => {
|
||||
const roles: MembershipRole[] = [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER];
|
||||
const modifyRoles: MembershipRole[] = [MembershipRole.OWNER, MembershipRole.ADMIN];
|
||||
|
||||
const canRead = roles.includes(role);
|
||||
const canModify = modifyRoles.includes(role);
|
||||
|
||||
return {
|
||||
canRead,
|
||||
canCreate: canModify,
|
||||
canUpdate: canModify,
|
||||
canDelete: canModify,
|
||||
};
|
||||
};
|
||||
|
||||
const getHighestRole = (
|
||||
role1: MembershipRole | null,
|
||||
role2: MembershipRole | null
|
||||
): MembershipRole | null => {
|
||||
if (!role1) return role2;
|
||||
if (!role2) return role1;
|
||||
|
||||
const roleHierarchy: Record<MembershipRole, number> = {
|
||||
[MembershipRole.OWNER]: 3,
|
||||
[MembershipRole.ADMIN]: 2,
|
||||
[MembershipRole.MEMBER]: 1,
|
||||
};
|
||||
|
||||
return roleHierarchy[role1] >= roleHierarchy[role2] ? role1 : role2;
|
||||
};
|
||||
|
||||
export async function getEventTypePermissions(
|
||||
userId: number,
|
||||
teamId: number | null
|
||||
): Promise<EventTypePermissions> {
|
||||
// Personal event - user has all permissions
|
||||
if (!teamId) {
|
||||
return createFullPermissions();
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
|
||||
// Check if PBAC is enabled for the team
|
||||
const isPBACEnabled = await featuresRepository.checkIfTeamHasFeature(teamId, "pbac");
|
||||
|
||||
if (isPBACEnabled) {
|
||||
const [eventTypePermissions, workflowPermissions] = await Promise.all([
|
||||
permissionCheckService.getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.EventType,
|
||||
}),
|
||||
permissionCheckService.getResourcePermissions({
|
||||
userId,
|
||||
teamId,
|
||||
resource: Resource.Workflow,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
eventTypes: permissionsArrayToObject(eventTypePermissions, Resource.EventType),
|
||||
workflows: permissionsArrayToObject(workflowPermissions, Resource.Workflow),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to role-based permissions when PBAC is not enabled
|
||||
// Check both team membership and org membership (via parentId)
|
||||
const [teamMembership, team] = await Promise.all([
|
||||
prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
}),
|
||||
prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
let effectiveRole: MembershipRole | null = teamMembership?.role ?? null;
|
||||
|
||||
// If the team has a parent (org), check for org membership
|
||||
if (team?.parentId) {
|
||||
const orgMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId: team.parentId,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Use the highest role between team and org
|
||||
effectiveRole = getHighestRole(effectiveRole, orgMembership?.role ?? null);
|
||||
}
|
||||
|
||||
if (!effectiveRole) {
|
||||
throw new Error("Membership not found");
|
||||
}
|
||||
|
||||
const rolePermissions = getRoleBasedPermissions(effectiveRole);
|
||||
|
||||
return {
|
||||
eventTypes: rolePermissions,
|
||||
workflows: rolePermissions,
|
||||
};
|
||||
}
|
||||
@@ -123,9 +123,10 @@ describe("PermissionCheckService", () => {
|
||||
expect(mockRepository.checkRolePermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return false if membership not found when PBAC disabled", async () => {
|
||||
it("should return false if no team or org membership found when PBAC disabled", async () => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(false);
|
||||
(MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(null);
|
||||
(MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValue(null);
|
||||
mockRepository.getTeamById.mockResolvedValueOnce({ id: 1, parentId: 100 });
|
||||
|
||||
const result = await service.checkPermission({
|
||||
userId: 1,
|
||||
@@ -135,6 +136,45 @@ describe("PermissionCheckService", () => {
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should use org membership when team membership not found (PBAC disabled)", async () => {
|
||||
const orgMembership = {
|
||||
id: 2,
|
||||
teamId: 100,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: "ADMIN" as MembershipRole,
|
||||
customRoleId: null,
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(false);
|
||||
(MembershipRepository.findUniqueByUserIdAndTeamId as Mock)
|
||||
.mockResolvedValueOnce(null) // No team membership
|
||||
.mockResolvedValueOnce(orgMembership); // Has org membership
|
||||
mockRepository.getTeamById.mockResolvedValueOnce({ id: 1, parentId: 100 });
|
||||
|
||||
const result = await service.checkPermission({
|
||||
userId: 1,
|
||||
teamId: 1,
|
||||
permission: "eventType.create",
|
||||
fallbackRoles: ["ADMIN", "OWNER"],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockRepository.getTeamById).toHaveBeenCalledWith(1);
|
||||
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenNthCalledWith(1, {
|
||||
userId: 1,
|
||||
teamId: 1,
|
||||
});
|
||||
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenNthCalledWith(2, {
|
||||
userId: 1,
|
||||
teamId: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should check org-level permissions when user has no team membership but PBAC is enabled", async () => {
|
||||
@@ -250,6 +290,44 @@ describe("PermissionCheckService", () => {
|
||||
expect(mockRepository.checkRolePermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use org membership for checkPermissions when team membership not found (PBAC disabled)", async () => {
|
||||
const orgMembership = {
|
||||
id: 2,
|
||||
teamId: 100,
|
||||
userId: 1,
|
||||
accepted: true,
|
||||
role: "ADMIN" as MembershipRole,
|
||||
customRoleId: null,
|
||||
disableImpersonation: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(false);
|
||||
(MembershipRepository.findUniqueByUserIdAndTeamId as Mock)
|
||||
.mockResolvedValueOnce(null) // No team membership
|
||||
.mockResolvedValueOnce(orgMembership); // Has org membership
|
||||
mockRepository.getTeamById.mockResolvedValueOnce({ id: 1, parentId: 100 });
|
||||
|
||||
const result = await service.checkPermissions({
|
||||
userId: 1,
|
||||
teamId: 1,
|
||||
permissions: ["eventType.create", "team.invite"],
|
||||
fallbackRoles: ["ADMIN", "OWNER"],
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockRepository.getTeamById).toHaveBeenCalledWith(1);
|
||||
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenNthCalledWith(1, {
|
||||
userId: 1,
|
||||
teamId: 1,
|
||||
});
|
||||
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenNthCalledWith(2, {
|
||||
userId: 1,
|
||||
teamId: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false when permissions array is empty", async () => {
|
||||
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
|
||||
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({
|
||||
|
||||
@@ -118,14 +118,30 @@ export class PermissionCheckService {
|
||||
return this.hasPermission({ userId, teamId }, permission);
|
||||
}
|
||||
|
||||
// Fallback to role-based check only if user has team membership
|
||||
// Fallback to role-based check - check both team and org membership
|
||||
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!membership) return false;
|
||||
return this.checkFallbackRoles(membership.role, fallbackRoles);
|
||||
// If user has team membership, check their role
|
||||
if (membership) {
|
||||
return this.checkFallbackRoles(membership.role, fallbackRoles);
|
||||
}
|
||||
|
||||
// No team membership - check if team has parent org and user is org member
|
||||
const team = await this.repository.getTeamById(teamId);
|
||||
if (team?.parentId) {
|
||||
const orgMembership = await MembershipRepository.findUniqueByUserIdAndTeamId({
|
||||
userId,
|
||||
teamId: team.parentId,
|
||||
});
|
||||
if (orgMembership) {
|
||||
return this.checkFallbackRoles(orgMembership.role, fallbackRoles);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
return false;
|
||||
@@ -163,14 +179,30 @@ export class PermissionCheckService {
|
||||
return this.hasPermissions({ userId, teamId }, permissions);
|
||||
}
|
||||
|
||||
// Fallback to role-based check only if user has team membership
|
||||
// Fallback to role-based check - check both team and org membership
|
||||
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
if (!membership) return false;
|
||||
return this.checkFallbackRoles(membership.role, fallbackRoles);
|
||||
// If user has team membership, check their role
|
||||
if (membership) {
|
||||
return this.checkFallbackRoles(membership.role, fallbackRoles);
|
||||
}
|
||||
|
||||
// No team membership - check if team has parent org and user is org member
|
||||
const team = await this.repository.getTeamById(teamId);
|
||||
if (team?.parentId) {
|
||||
const orgMembership = await MembershipRepository.findUniqueByUserIdAndTeamId({
|
||||
userId,
|
||||
teamId: team.parentId,
|
||||
});
|
||||
if (orgMembership) {
|
||||
return this.checkFallbackRoles(orgMembership.role, fallbackRoles);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
|
||||
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { authedProcedure } from "../../../procedures/authedProcedure";
|
||||
import { createEventPbacProcedure } from "../util";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/features/pbac/services/permission-check.service");
|
||||
|
||||
describe("createEventPbacProcedure", () => {
|
||||
let mockPermissionCheckService: {
|
||||
checkPermission: Mock;
|
||||
};
|
||||
|
||||
const mockPrisma = {
|
||||
eventType: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
|
||||
const mockCtx = {
|
||||
user: { id: 1, profile: { upId: "user-1" } },
|
||||
session: { user: { id: 1 } },
|
||||
prisma: mockPrisma,
|
||||
};
|
||||
|
||||
const mockNext = vi.fn().mockResolvedValue({ ctx: mockCtx });
|
||||
|
||||
// Helper to get the custom middleware (after authedProcedure)
|
||||
const getMiddleware = (procedure: ReturnType<typeof authedProcedure>) => {
|
||||
// The last middleware is our custom one
|
||||
return procedure._def.middlewares[procedure._def.middlewares.length - 1];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockPermissionCheckService = {
|
||||
checkPermission: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(PermissionCheckService).mockImplementation(() => mockPermissionCheckService as any);
|
||||
});
|
||||
|
||||
describe("personal events", () => {
|
||||
const personalEvent = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
users: [{ id: 1 }],
|
||||
team: null,
|
||||
};
|
||||
|
||||
it("should allow owner to access their personal event", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(personalEvent);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await expect(
|
||||
middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 1 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should allow assigned user to access personal event", async () => {
|
||||
mockPrisma.eventType.findUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ...personalEvent, userId: 2, users: [{ id: 1 }, { id: 2 }] });
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await expect(
|
||||
middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 1 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should deny non-owner/non-assigned user from accessing personal event", async () => {
|
||||
mockPrisma.eventType.findUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ...personalEvent, userId: 2, users: [{ id: 2 }] });
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await expect(
|
||||
middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 1 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
})
|
||||
).rejects.toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("should deny team member from accessing another user's personal event", async () => {
|
||||
// User 1 trying to access user 2's personal event
|
||||
mockPrisma.eventType.findUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ...personalEvent, userId: 2, users: [{ id: 2 }] });
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
const result = middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 1 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow(TRPCError);
|
||||
await expect(result).rejects.toThrow("Permission required: eventType.update");
|
||||
});
|
||||
|
||||
it("should only allow assigning self to personal event", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(personalEvent);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await expect(
|
||||
middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 1, users: [1] },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should deny assigning other users to personal event", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(personalEvent);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
const result = middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 1, users: [1, 2] },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow(TRPCError);
|
||||
await expect(result).rejects.toThrow("Cannot assign event to users outside of team membership");
|
||||
});
|
||||
});
|
||||
|
||||
describe("team events - with permission", () => {
|
||||
const teamEvent = {
|
||||
id: 2,
|
||||
userId: null,
|
||||
teamId: 10,
|
||||
users: [],
|
||||
team: {
|
||||
members: [{ userId: 1 }, { userId: 2 }, { userId: 3 }],
|
||||
},
|
||||
};
|
||||
|
||||
it("should allow team member with permission to access team event", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update", [
|
||||
MembershipRole.ADMIN,
|
||||
MembershipRole.OWNER,
|
||||
]);
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await expect(
|
||||
middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith({
|
||||
userId: 1,
|
||||
teamId: 10,
|
||||
permission: "eventType.update",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
});
|
||||
|
||||
it("should deny team member without permission from accessing team event", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(false);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
const result = middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow(TRPCError);
|
||||
await expect(result).rejects.toThrow("Permission required: eventType.update");
|
||||
});
|
||||
|
||||
it("should allow org admin without team membership to access team event", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await expect(
|
||||
middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("team events - user assignment validation", () => {
|
||||
const teamEvent = {
|
||||
id: 2,
|
||||
userId: null,
|
||||
teamId: 10,
|
||||
users: [],
|
||||
team: {
|
||||
members: [{ userId: 1 }, { userId: 2 }, { userId: 3 }],
|
||||
},
|
||||
};
|
||||
|
||||
it("should allow assigning team members to team event", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await expect(
|
||||
middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2, users: [1, 2, 3] },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should deny assigning non-team members to team event", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
const result = middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2, users: [1, 2, 999] },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow(TRPCError);
|
||||
await expect(result).rejects.toThrow("Cannot assign event to users outside of team membership");
|
||||
});
|
||||
|
||||
it("should deny org admin from assigning org members who are not in the team", async () => {
|
||||
// Org admin (user 1) has permission but tries to assign org member (user 50) who's not in team
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
const result = middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2, users: [1, 50] }, // User 50 is an org member but not team member
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow(TRPCError);
|
||||
await expect(result).rejects.toThrow("Cannot assign event to users outside of team membership");
|
||||
});
|
||||
|
||||
it("should deny assigning only non-team members even if user has permission", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
const result = middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2, users: [100, 200, 300] }, // All users not in team
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow(TRPCError);
|
||||
await expect(result).rejects.toThrow("Cannot assign event to users outside of team membership");
|
||||
});
|
||||
|
||||
it("should allow empty users array", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await expect(
|
||||
middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2, users: [] },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("should not validate users when not provided", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await expect(
|
||||
middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("event not found", () => {
|
||||
it("should throw NOT_FOUND when event does not exist", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(null);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
const result = middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 999 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow(TRPCError);
|
||||
await expect(result).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("input validation", () => {
|
||||
it("should accept eventTypeId as alternative to id", async () => {
|
||||
const event = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
teamId: null,
|
||||
users: [{ id: 1 }],
|
||||
team: null,
|
||||
};
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(event);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.update");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await expect(
|
||||
middleware({
|
||||
ctx: mockCtx,
|
||||
input: { eventTypeId: 1 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(mockPrisma.eventType.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("different permissions and fallback roles", () => {
|
||||
const teamEvent = {
|
||||
id: 2,
|
||||
userId: null,
|
||||
teamId: 10,
|
||||
users: [],
|
||||
team: {
|
||||
members: [{ userId: 1 }],
|
||||
},
|
||||
};
|
||||
|
||||
it("should use custom permission string", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.delete");
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
permission: "eventType.delete",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should use custom fallback roles", async () => {
|
||||
mockPrisma.eventType.findUnique = vi.fn().mockResolvedValue(teamEvent);
|
||||
mockPermissionCheckService.checkPermission.mockResolvedValue(true);
|
||||
|
||||
const procedure = createEventPbacProcedure("eventType.create", [MembershipRole.OWNER]);
|
||||
const middleware = getMiddleware(procedure);
|
||||
|
||||
await middleware({
|
||||
ctx: mockCtx,
|
||||
input: { id: 2 },
|
||||
next: mockNext,
|
||||
path: "test",
|
||||
type: "mutation",
|
||||
getRawInput: async () => ({}),
|
||||
meta: undefined,
|
||||
});
|
||||
|
||||
expect(mockPermissionCheckService.checkPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fallbackRoles: [MembershipRole.OWNER],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,19 @@
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { ZGetInputSchema } from "../get.schema";
|
||||
import { eventOwnerProcedure } from "../util";
|
||||
import { createEventPbacProcedure } from "../util";
|
||||
|
||||
export const get = eventOwnerProcedure.input(ZGetInputSchema).query(async ({ ctx, input }) => {
|
||||
const handler = (await import("../get.handler")).getHandler;
|
||||
export const get = createEventPbacProcedure("eventType.read", [
|
||||
MembershipRole.OWNER,
|
||||
MembershipRole.ADMIN,
|
||||
MembershipRole.MEMBER,
|
||||
])
|
||||
.input(ZGetInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const handler = (await import("../get.handler")).getHandler;
|
||||
|
||||
return handler({
|
||||
ctx,
|
||||
input,
|
||||
return handler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,50 +98,99 @@ export const createEventPbacProcedure = (
|
||||
permission: PermissionString,
|
||||
fallbackRoles: MembershipRole[] = ["ADMIN", "OWNER"]
|
||||
) => {
|
||||
return eventOwnerProcedure.use(async ({ ctx, input, next }) => {
|
||||
const id = input.eventTypeId ?? input.id;
|
||||
return authedProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
id: z.number().optional(),
|
||||
eventTypeId: z.number().optional(),
|
||||
users: z.array(z.number()).optional(),
|
||||
})
|
||||
.refine((data) => data.id !== undefined || data.eventTypeId !== undefined, {
|
||||
message: "At least one of 'id' or 'eventTypeId' must be present",
|
||||
path: ["id", "eventTypeId"],
|
||||
})
|
||||
)
|
||||
.use(async ({ ctx, input, next }) => {
|
||||
const id = input.eventTypeId ?? input.id;
|
||||
|
||||
const event = await ctx.prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
// For team events, check PBAC permissions
|
||||
if (event.teamId) {
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId: event.teamId,
|
||||
permission,
|
||||
fallbackRoles,
|
||||
const event = await ctx.prisma.eventType.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Permission required: ${permission}`,
|
||||
});
|
||||
if (!event) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
} else {
|
||||
// For personal events, only the owner can manage
|
||||
if (event.userId !== ctx.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Permission required: ${permission}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
// Check if user has permission to access/modify this event
|
||||
if (!event.teamId) {
|
||||
// Personal event - must be owner or assigned user
|
||||
if (event.userId !== ctx.user.id && !event.users.find((user) => user.id === ctx.user.id)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Permission required: ${permission}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Team event - check PBAC/fallback permissions
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId: ctx.user.id,
|
||||
teamId: event.teamId,
|
||||
permission,
|
||||
fallbackRoles,
|
||||
});
|
||||
|
||||
if (!hasPermission) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Permission required: ${permission}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that assigned users are allowed
|
||||
if (input.users && input.users.length > 0) {
|
||||
const isAllowed = (function () {
|
||||
if (event.team) {
|
||||
const allTeamMembers = event.team.members.map((member) => member.userId);
|
||||
return input.users!.every((userId: number) => allTeamMembers.includes(userId));
|
||||
}
|
||||
return input.users!.every((userId: number) => userId === ctx.user.id);
|
||||
})();
|
||||
|
||||
if (!isAllowed) {
|
||||
console.warn(
|
||||
`User ${ctx.user.id} attempted to assign event ${event.id} to users ${input.users.join(", ")}.`
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Cannot assign event to users outside of team membership",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
};
|
||||
|
||||
export function isPeriodType(keyInput: string): keyInput is PeriodType {
|
||||
|
||||
Reference in New Issue
Block a user