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 {