diff --git a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx
index 116c8ff97f..bbe79e13f1 100644
--- a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx
+++ b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx
@@ -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 ;
};
diff --git a/packages/features/pbac/lib/__tests__/event-type-permissions.test.ts b/packages/features/pbac/lib/__tests__/event-type-permissions.test.ts
new file mode 100644
index 0000000000..adf7904bec
--- /dev/null
+++ b/packages/features/pbac/lib/__tests__/event-type-permissions.test.ts
@@ -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));
+ });
+ });
+});
diff --git a/packages/features/pbac/lib/event-type-permissions.ts b/packages/features/pbac/lib/event-type-permissions.ts
new file mode 100644
index 0000000000..7cb33a662f
--- /dev/null
+++ b/packages/features/pbac/lib/event-type-permissions.ts
@@ -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.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 {
+ // 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,
+ };
+}
diff --git a/packages/features/pbac/services/__tests__/permission-check.service.test.ts b/packages/features/pbac/services/__tests__/permission-check.service.test.ts
index 360f426f16..9e9fe4bb73 100644
--- a/packages/features/pbac/services/__tests__/permission-check.service.test.ts
+++ b/packages/features/pbac/services/__tests__/permission-check.service.test.ts
@@ -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({
diff --git a/packages/features/pbac/services/permission-check.service.ts b/packages/features/pbac/services/permission-check.service.ts
index a926aa72db..2a09b5d356 100644
--- a/packages/features/pbac/services/permission-check.service.ts
+++ b/packages/features/pbac/services/permission-check.service.ts
@@ -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;
diff --git a/packages/trpc/server/routers/viewer/eventTypes/__tests__/util.test.ts b/packages/trpc/server/routers/viewer/eventTypes/__tests__/util.test.ts
new file mode 100644
index 0000000000..1bf45f4e5b
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/eventTypes/__tests__/util.test.ts
@@ -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) => {
+ // 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],
+ })
+ );
+ });
+ });
+});
diff --git a/packages/trpc/server/routers/viewer/eventTypes/procedures/get.ts b/packages/trpc/server/routers/viewer/eventTypes/procedures/get.ts
index 96d0a4413e..63db98a1bf 100644
--- a/packages/trpc/server/routers/viewer/eventTypes/procedures/get.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes/procedures/get.ts
@@ -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,
+ });
});
-});
diff --git a/packages/trpc/server/routers/viewer/eventTypes/util.ts b/packages/trpc/server/routers/viewer/eventTypes/util.ts
index 93e1a27590..db92c61e50 100644
--- a/packages/trpc/server/routers/viewer/eventTypes/util.ts
+++ b/packages/trpc/server/routers/viewer/eventTypes/util.ts
@@ -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 {