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:
sean-brydon
2025-10-09 12:30:12 -03:00
committed by GitHub
co-authored by coderabbitai[bot]
parent 8277ce934b
commit 5aa24c8472
8 changed files with 1482 additions and 148 deletions
@@ -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 {