diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c6d26045b9357d..7f294dbc14cc48 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3288,6 +3288,7 @@ "pbac_resource_booking": "Bookings", "pbac_resource_insights": "Insights", "pbac_resource_role": "Roles", + "pbac_resource_workflow": "Workflows", "pbac_action_all": "All Actions", "pbac_action_create": "Create", "pbac_action_read": "View", @@ -3319,6 +3320,11 @@ "pbac_desc_update_roles": "Update roles", "pbac_desc_delete_roles": "Delete roles", "pbac_desc_manage_roles": "All actions on roles across organization teams", + "pbac_desc_create_workflows": "Create and set up new workflows", + "pbac_desc_view_workflows": "View existing workflows and their configurations", + "pbac_desc_update_workflows": "Edit and modify workflow settings", + "pbac_desc_delete_workflows": "Remove workflows from the system", + "pbac_desc_manage_workflows": "Full management access to all workflows", "pbac_desc_create_event_types": "Create event types", "pbac_desc_view_event_types": "View event types", "pbac_desc_update_event_types": "Update event types", diff --git a/packages/features/ee/teams/components/createButton/CreateButtonWithTeamsList.tsx b/packages/features/ee/teams/components/createButton/CreateButtonWithTeamsList.tsx index aa68971a0ed5d2..d88c85452a2952 100644 --- a/packages/features/ee/teams/components/createButton/CreateButtonWithTeamsList.tsx +++ b/packages/features/ee/teams/components/createButton/CreateButtonWithTeamsList.tsx @@ -1,5 +1,7 @@ "use client"; +import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; +import type { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; import type { CreateBtnProps, Option } from "./CreateButton"; @@ -11,10 +13,20 @@ export function CreateButtonWithTeamsList( onlyShowWithNoTeams?: boolean; isAdmin?: boolean; includeOrg?: boolean; + withPermission?: { + permission: PermissionString; + fallbackRoles?: MembershipRole[]; + }; } ) { const query = trpc.viewer.loggedInViewerRouter.teamsAndUserProfilesQuery.useQuery({ includeOrg: props.includeOrg, + withPermission: props.withPermission + ? { + permission: props.withPermission.permission, + fallbackRoles: props.withPermission.fallbackRoles, + } + : undefined, }); if (!query.data) return null; diff --git a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx index bb7aa5c6f8090b..f078c3897f291c 100644 --- a/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx +++ b/packages/features/ee/workflows/components/WorkflowDetailsPage.tsx @@ -23,6 +23,14 @@ import WorkflowStepContainer from "./WorkflowStepContainer"; type User = RouterOutputs["viewer"]["me"]["get"]; +interface WorkflowPermissions { + canView: boolean; + canUpdate: boolean; + canDelete: boolean; + canManage: boolean; + readOnly: boolean; // Keep for backward compatibility +} + interface Props { form: UseFormReturn; workflowId: number; @@ -33,13 +41,31 @@ interface Props { readOnly: boolean; isOrg: boolean; allOptions: Option[]; + permissions?: WorkflowPermissions; } export default function WorkflowDetailsPage(props: Props) { - const { form, workflowId, selectedOptions, setSelectedOptions, teamId, isOrg, allOptions } = props; + const { + form, + workflowId, + selectedOptions, + setSelectedOptions, + teamId, + isOrg, + allOptions, + permissions: _permissions, + } = props; const { t } = useLocale(); const router = useRouter(); + const permissions = _permissions || { + canView: !teamId ? true : !props.readOnly, + canUpdate: !teamId ? true : !props.readOnly, + canDelete: !teamId ? true : !props.readOnly, + canManage: !teamId ? true : !props.readOnly, + readOnly: !teamId ? false : props.readOnly, + }; + const [isAddActionDialogOpen, setIsAddActionDialogOpen] = useState(false); const [reload, setReload] = useState(false); @@ -160,7 +186,7 @@ export default function WorkflowDetailsPage(props: Props) { />
- {!props.readOnly && ( + {permissions.canDelete && (
diff --git a/packages/features/ee/workflows/pages/index.tsx b/packages/features/ee/workflows/pages/index.tsx index 62b07643daf52f..a18fba663bda19 100644 --- a/packages/features/ee/workflows/pages/index.tsx +++ b/packages/features/ee/workflows/pages/index.tsx @@ -84,6 +84,10 @@ function WorkflowsPage({ filteredList }: PageProps) { disableMobileButton={true} onlyShowWithNoTeams={true} includeOrg={true} + withPermission={{ + permission: "workflow.create", + fallbackRoles: ["ADMIN", "OWNER"], + }} /> ) : null }> @@ -99,6 +103,10 @@ function WorkflowsPage({ filteredList }: PageProps) { disableMobileButton={true} onlyShowWithTeams={true} includeOrg={true} + withPermission={{ + permission: "workflow.create", + fallbackRoles: ["ADMIN", "OWNER"], + }} /> diff --git a/packages/features/ee/workflows/pages/workflow.tsx b/packages/features/ee/workflows/pages/workflow.tsx index b6353ca7e6fcc4..08efe2b0e4f68c 100644 --- a/packages/features/ee/workflows/pages/workflow.tsx +++ b/packages/features/ee/workflows/pages/workflow.tsx @@ -10,9 +10,8 @@ import Shell, { ShellMain } from "@calcom/features/shell/Shell"; import { SENDER_ID } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { HttpError } from "@calcom/lib/http-error"; -import type { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import type { TimeUnit, WorkflowTriggerEvents } from "@calcom/prisma/enums"; -import { MembershipRole, WorkflowActions } from "@calcom/prisma/enums"; +import { WorkflowActions } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; @@ -43,17 +42,9 @@ export type FormValues = { type PageProps = { workflow: number; - workflowData?: Awaited>; - verifiedNumbers?: Awaited>; - verifiedEmails?: Awaited>; }; -function WorkflowPage({ - workflow: workflowId, - workflowData: workflowDataProp, - verifiedNumbers: verifiedNumbersProp, - verifiedEmails: verifiedEmailsProp, -}: PageProps) { +function WorkflowPage({ workflow: workflowId }: PageProps) { const { t, i18n } = useLocale(); const session = useSession(); @@ -73,35 +64,23 @@ function WorkflowPage({ const { data: workflowData, - isError: _isError, + isError, error, - isPending: _isPendingWorkflow, - } = trpc.viewer.workflows.get.useQuery( - { id: +workflowId }, - { - enabled: workflowDataProp ? false : !!workflowId, - } - ); + isPending: isPendingWorkflow, + } = trpc.viewer.workflows.get.useQuery({ id: +workflowId }); - const workflow = workflowDataProp || workflowData; - const isPendingWorkflow = workflowDataProp ? false : _isPendingWorkflow; - const isError = workflowDataProp ? false : _isError; + const workflow = workflowData; - const { data: verifiedNumbersData } = trpc.viewer.workflows.getVerifiedNumbers.useQuery( + const { data: verifiedNumbers } = trpc.viewer.workflows.getVerifiedNumbers.useQuery( { teamId: workflow?.team?.id }, { - enabled: verifiedNumbersProp ? false : !!workflow?.id, + enabled: !!workflow?.id, } ); - const verifiedNumbers = verifiedNumbersProp || verifiedNumbersData; - const { data: verifiedEmailsData } = trpc.viewer.workflows.getVerifiedEmails.useQuery( - { - teamId: workflow?.team?.id, - }, - { enabled: !verifiedEmailsProp } - ); - const verifiedEmails = verifiedEmailsProp || verifiedEmailsData; + const { data: verifiedEmails } = trpc.viewer.workflows.getVerifiedEmails.useQuery({ + teamId: workflow?.team?.id, + }); const isOrg = workflow?.team?.isOrganization ?? false; @@ -126,9 +105,7 @@ function WorkflowPage({ }); } - const readOnly = - workflow?.team?.members?.find((member) => member.userId === session.data?.user.id)?.role === - MembershipRole.MEMBER; + const readOnly = !workflow?.permissions.canUpdate; const isPending = isPendingWorkflow || isPendingEventTypes; @@ -210,8 +187,8 @@ function WorkflowPage({ const updateMutation = trpc.viewer.workflows.update.useMutation({ onSuccess: async ({ workflow }) => { if (workflow) { - utils.viewer.workflows.get.setData({ id: +workflow.id }, workflow); - setFormData(workflow); + await utils.viewer.workflows.get.invalidate({ id: +workflow.id }); + showToast( t("workflow_updated_successfully", { workflowName: workflow.name, @@ -348,6 +325,7 @@ function WorkflowPage({ {isAllDataLoaded && user ? ( <> { + const cacheKey = teamId.toString(); + + if (this.teamPermissionsCache[cacheKey]) { + return this.teamPermissionsCache[cacheKey]; + } + + // Create a mock workflow object for team permission checking + const mockWorkflow = { id: 0, teamId, userId: null }; + + // Check all permissions in parallel for better performance + const [canView, canUpdate, canDelete, canManage] = await Promise.all([ + isAuthorized(mockWorkflow, this.currentUserId, "workflow.read"), + isAuthorized(mockWorkflow, this.currentUserId, "workflow.update"), + isAuthorized(mockWorkflow, this.currentUserId, "workflow.delete"), + isAuthorized(mockWorkflow, this.currentUserId, "workflow.manage"), + ]); + + const permissions = { + canView, + canUpdate, + canDelete, + canManage, + readOnly: !canUpdate, + }; + + this.teamPermissionsCache[cacheKey] = permissions; + return permissions; + } + + /** + * Get permissions for a personal workflow + */ + private getPersonalWorkflowPermissions(workflow: Pick): WorkflowPermissions { + const isOwner = workflow.userId === this.currentUserId; + return { + canView: isOwner, + canUpdate: isOwner, + canDelete: isOwner, + canManage: isOwner, + readOnly: !isOwner, + }; + } + + /** + * Build permissions for a single workflow + */ + async buildPermissions( + workflow: Pick | null + ): Promise { + if (!workflow) { + return { + canView: false, + canUpdate: false, + canDelete: false, + canManage: false, + readOnly: true, + }; + } + + // Personal workflow + if (!workflow.teamId) { + return this.getPersonalWorkflowPermissions(workflow); + } + + // Team workflow + return await this.getTeamPermissions(workflow.teamId); + } + + /** + * Batch build permissions for multiple workflows (optimized) + */ + async buildPermissionsForWorkflows>( + workflows: T[] + ): Promise<(T & { permissions: WorkflowPermissions; readOnly: boolean })[]> { + // Pre-fetch permissions for all unique teams + const teamIds = workflows.filter((w) => w.teamId).map((w) => w.teamId!); + const uniqueTeamIds = teamIds.filter((id, index) => teamIds.indexOf(id) === index); + await Promise.all(uniqueTeamIds.map((teamId) => this.getTeamPermissions(teamId))); + + // Now build permissions for each workflow (using cache) + const result = await Promise.all( + workflows.map(async (workflow) => { + const permissions = await this.buildPermissions(workflow); + return { + ...workflow, + permissions, + readOnly: permissions.readOnly, + }; + }) + ); + + return result; + } + + /** + * Static factory method for convenience + */ + static async buildPermissions( + workflow: Pick | null, + currentUserId: number + ): Promise { + const builder = new WorkflowPermissionsBuilder(currentUserId); + return await builder.buildPermissions(workflow); + } + + /** + * Static method for batch processing + */ + static async buildPermissionsForWorkflows>( + workflows: T[], + currentUserId: number + ): Promise<(T & { permissions: WorkflowPermissions; readOnly: boolean })[]> { + const builder = new WorkflowPermissionsBuilder(currentUserId); + return await builder.buildPermissionsForWorkflows(workflows); + } +} + +/** + * Utility function to add permissions to a single workflow + */ +export async function addPermissionsToWorkflow>( + workflow: T, + currentUserId: number +): Promise { + const permissions = await WorkflowPermissionsBuilder.buildPermissions(workflow, currentUserId); + return { + ...workflow, + permissions, + readOnly: permissions.readOnly, + }; +} + +/** + * Utility function to add permissions to multiple workflows (optimized) + */ +export async function addPermissionsToWorkflows>( + workflows: T[], + currentUserId: number +): Promise<(T & { permissions: WorkflowPermissions; readOnly: boolean })[]> { + return await WorkflowPermissionsBuilder.buildPermissionsForWorkflows(workflows, currentUserId); +} diff --git a/packages/lib/server/repository/workflow.ts b/packages/lib/server/repository/workflow.ts index 081e38dfd759ae..a2d4136e87954e 100644 --- a/packages/lib/server/repository/workflow.ts +++ b/packages/lib/server/repository/workflow.ts @@ -6,7 +6,6 @@ import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/s import type { WorkflowStep } from "@calcom/ee/workflows/lib/types"; import { hasFilter } from "@calcom/features/filters/lib/hasFilter"; import prisma from "@calcom/prisma"; -import { MembershipRole } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client"; import { WorkflowMethods } from "@calcom/prisma/enums"; import type { TFilteredListInputSchema } from "@calcom/trpc/server/routers/viewer/workflows/filteredList.schema"; @@ -244,7 +243,7 @@ export class WorkflowRepository { if (!filtered) { const workflowsWithReadOnly: WorkflowType[] = allWorkflows.map((workflow) => { const readOnly = !!workflow.team?.members?.find( - (member) => member.userId === userId && member.role === MembershipRole.MEMBER + (member) => member.userId === userId && member.role === "MEMBER" ); return { readOnly, isOrg: workflow.team?.isOrganization ?? false, ...workflow }; @@ -296,7 +295,7 @@ export class WorkflowRepository { const workflowsWithReadOnly: WorkflowType[] = filteredWorkflows.map((workflow) => { const readOnly = !!workflow.team?.members?.find( - (member) => member.userId === userId && member.role === MembershipRole.MEMBER + (member) => member.userId === userId && member.role === "MEMBER" ); return { readOnly, isOrg: workflow.team?.isOrganization ?? false, ...workflow }; diff --git a/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts b/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts index 9c737449110920..e34cb83c2f608b 100644 --- a/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.handler.ts @@ -1,7 +1,10 @@ +import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { withRoleCanCreateEntity } from "@calcom/lib/entityPermissionUtils.server"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import type { PrismaClient } from "@calcom/prisma"; +import type { MembershipRole } from "@calcom/prisma/enums"; import { teamMetadataSchema } from "@calcom/prisma/zod-utils"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; @@ -89,6 +92,29 @@ export const teamsAndUserProfilesQuery = async ({ ctx, input }: TeamsAndUserProf })); } + // Filter teams based on permission if provided + let hasPermissionForFiltered: boolean[] = []; + if (input?.withPermission) { + const permissionService = new PermissionCheckService(); + const { permission, fallbackRoles } = input.withPermission; + + const permissionChecks = await Promise.all( + teamsData.map((membership) => + permissionService.checkPermission({ + userId: ctx.user.id, + teamId: membership.team.id, + permission: permission as PermissionString, + fallbackRoles: fallbackRoles ? (fallbackRoles as MembershipRole[]) : [], + }) + ) + ); + + // Store permission results for teams that passed the filter + hasPermissionForFiltered = permissionChecks.filter((hasPermission) => hasPermission); + teamsData = teamsData.filter((_, index) => permissionChecks[index]); + + } + return [ { teamId: null, @@ -99,7 +125,7 @@ export const teamsAndUserProfilesQuery = async ({ ctx, input }: TeamsAndUserProf }), readOnly: false, }, - ...teamsData.map((membership) => ({ + ...teamsData.map((membership, index) => ({ teamId: membership.team.id, name: membership.team.name, slug: membership.team.slug ? `team/${membership.team.slug}` : null, @@ -107,7 +133,9 @@ export const teamsAndUserProfilesQuery = async ({ ctx, input }: TeamsAndUserProf ? getPlaceholderAvatar(membership.team.parent.logoUrl, membership.team.parent.name) : getPlaceholderAvatar(membership.team.logoUrl, membership.team.name), role: membership.role, - readOnly: !withRoleCanCreateEntity(membership.role), + readOnly: input?.withPermission + ? !hasPermissionForFiltered[index] + : !withRoleCanCreateEntity(membership.role), })), ]; }; diff --git a/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.schema.ts b/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.schema.ts index bcf20f4aa672ba..21ca581ed568d4 100644 --- a/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.schema.ts +++ b/packages/trpc/server/routers/loggedInViewer/teamsAndUserProfilesQuery.schema.ts @@ -3,6 +3,12 @@ import { z } from "zod"; export const ZTeamsAndUserProfilesQueryInputSchema = z .object({ includeOrg: z.boolean().optional(), + withPermission: z + .object({ + permission: z.string(), + fallbackRoles: z.array(z.string()).optional(), + }) + .optional(), }) .optional(); diff --git a/packages/trpc/server/routers/viewer/workflows/create.handler.ts b/packages/trpc/server/routers/viewer/workflows/create.handler.ts index 007d9aaffd4751..41216f6d5cc000 100644 --- a/packages/trpc/server/routers/viewer/workflows/create.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/create.handler.ts @@ -1,6 +1,7 @@ import type { Workflow } from "@prisma/client"; import emailReminderTemplate from "@calcom/ee/workflows/lib/reminders/templates/emailReminderTemplate"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { SENDER_NAME } from "@calcom/lib/constants"; import { getTranslation } from "@calcom/lib/server/i18n"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; @@ -33,22 +34,16 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { const userId = ctx.user.id; if (teamId) { - const team = await prisma.team.findFirst({ - where: { - id: teamId, - members: { - some: { - userId: ctx.user.id, - accepted: true, - NOT: { - role: MembershipRole.MEMBER, - }, - }, - }, - }, + const permissionService = new PermissionCheckService(); + + const hasPermission = await permissionService.checkPermission({ + userId, + teamId, + permission: "workflow.create", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], }); - if (!team) { + if (!hasPermission) { throw new TRPCError({ code: "UNAUTHORIZED", }); diff --git a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts index 856447c1f7a68a..832e1d652557da 100644 --- a/packages/trpc/server/routers/viewer/workflows/delete.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/delete.handler.ts @@ -32,7 +32,7 @@ export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { }, }); - const isUserAuthorized = await isAuthorized(workflowToDelete, ctx.user.id, true); + const isUserAuthorized = await isAuthorized(workflowToDelete, ctx.user.id, "workflow.delete"); if (!isUserAuthorized || !workflowToDelete) { throw new TRPCError({ code: "UNAUTHORIZED" }); diff --git a/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx b/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx index 49a7aa9528b7bd..85076a2ca53d89 100644 --- a/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx +++ b/packages/trpc/server/routers/viewer/workflows/filteredList.handler.tsx @@ -1,4 +1,5 @@ import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; +import { addPermissionsToWorkflows } from "@calcom/lib/server/repository/workflow-permissions"; import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; @@ -56,5 +57,19 @@ const { include: includedFields } = { } satisfies Prisma.WorkflowDefaultArgs; export const filteredListHandler = async ({ ctx, input }: FilteredListOptions) => { - return await WorkflowRepository.getFilteredList({ userId: ctx.user.id, input }); + const result = await WorkflowRepository.getFilteredList({ userId: ctx.user.id, input }); + + if (!result) { + return result; + } + + // Add permissions to each workflow + const workflowsWithPermissions = await addPermissionsToWorkflows(result.filtered, ctx.user.id); + + const filteredWorkflows = workflowsWithPermissions.filter((workflow) => workflow.permissions.canView); + + return { + ...result, + filtered: filteredWorkflows, + }; }; diff --git a/packages/trpc/server/routers/viewer/workflows/get.handler.ts b/packages/trpc/server/routers/viewer/workflows/get.handler.ts index 20e26559dfe29e..f9f794f5c63113 100644 --- a/packages/trpc/server/routers/viewer/workflows/get.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/get.handler.ts @@ -1,4 +1,5 @@ import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; +import { addPermissionsToWorkflow } from "@calcom/lib/server/repository/workflow-permissions"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; @@ -16,7 +17,7 @@ type GetOptions = { export const getHandler = async ({ ctx, input }: GetOptions) => { const workflow = await WorkflowRepository.getById({ id: input.id }); - const isUserAuthorized = await isAuthorized(workflow, ctx.user.id); + const isUserAuthorized = await isAuthorized(workflow, ctx.user.id, "workflow.read"); if (!isUserAuthorized) { throw new TRPCError({ @@ -24,5 +25,12 @@ export const getHandler = async ({ ctx, input }: GetOptions) => { }); } - return workflow; + if (!workflow) { + return workflow; + } + + // Add permissions to the workflow + const workflowWithPermissions = await addPermissionsToWorkflow(workflow, ctx.user.id); + + return workflowWithPermissions; }; diff --git a/packages/trpc/server/routers/viewer/workflows/update.handler.ts b/packages/trpc/server/routers/viewer/workflows/update.handler.ts index 27463af098cb48..a371cd4d3a887d 100755 --- a/packages/trpc/server/routers/viewer/workflows/update.handler.ts +++ b/packages/trpc/server/routers/viewer/workflows/update.handler.ts @@ -1,10 +1,11 @@ import { isEmailAction } from "@calcom/features/ee/workflows/lib/actionHelperFunctions"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import tasker from "@calcom/features/tasker"; import { IS_SELF_HOSTED, SCANNING_WORKFLOW_STEPS } from "@calcom/lib/constants"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import { WorkflowRepository } from "@calcom/lib/server/repository/workflow"; import type { PrismaClient } from "@calcom/prisma"; -import { WorkflowActions, WorkflowTemplates } from "@calcom/prisma/enums"; +import { WorkflowActions, WorkflowTemplates, MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; @@ -66,7 +67,18 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const isOrg = !!userWorkflow?.team?.isOrganization; - const isUserAuthorized = await isAuthorized(userWorkflow, ctx.user.id, true); + let isUserAuthorized = false; + if (userWorkflow?.teamId) { + const permissionService = new PermissionCheckService(); + isUserAuthorized = await permissionService.checkPermission({ + userId: ctx.user.id, + teamId: userWorkflow.teamId, + permission: "workflow.update", + fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }); + } else { + isUserAuthorized = await isAuthorized(userWorkflow, ctx.user.id, "workflow.update"); + } if (!isUserAuthorized || !userWorkflow) { throw new TRPCError({ code: "UNAUTHORIZED" }); @@ -294,7 +306,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { let newStep; if (foundStep) { - const { senderName, ...rest } = { + const { senderName: _senderName, ...rest } = { ...foundStep, numberVerificationPending: false, sender: getSender({ @@ -441,7 +453,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { const { id: _stepId, - senderName, + senderName: _senderName, ...stepToAdd } = { ...newStep, diff --git a/packages/trpc/server/routers/viewer/workflows/util.test.ts b/packages/trpc/server/routers/viewer/workflows/util.test.ts new file mode 100644 index 00000000000000..a494b3e7053f29 --- /dev/null +++ b/packages/trpc/server/routers/viewer/workflows/util.test.ts @@ -0,0 +1,393 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; + +import { isAuthorized } from "./util"; + +vi.mock("@calcom/features/pbac/services/permission-check.service"); + +describe("isAuthorized", () => { + const mockPermissionCheckService = vi.mocked(PermissionCheckService); + let mockCheckPermission: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockCheckPermission = vi.fn(); + mockPermissionCheckService.mockImplementation( + () => + ({ + checkPermission: mockCheckPermission, + } as any) + ); + }); + + describe("null workflow", () => { + it("should return false when workflow is null", async () => { + const result = await isAuthorized(null, 123); + expect(result).toBe(false); + }); + }); + + describe("personal workflows (no teamId)", () => { + it("should return true when user owns the personal workflow", async () => { + const workflow = { + id: 1, + teamId: null, + userId: 123, + }; + + const result = await isAuthorized(workflow, 123); + expect(result).toBe(true); + expect(mockPermissionCheckService).not.toHaveBeenCalled(); + }); + + it("should return false when user does not own the personal workflow", async () => { + const workflow = { + id: 1, + teamId: null, + userId: 456, + }; + + const result = await isAuthorized(workflow, 123); + expect(result).toBe(false); + expect(mockPermissionCheckService).not.toHaveBeenCalled(); + }); + + it("should ignore permission parameter for personal workflows", async () => { + const workflow = { + id: 1, + teamId: null, + userId: 123, + }; + + const readResult = await isAuthorized(workflow, 123, "workflow.read"); + const updateResult = await isAuthorized(workflow, 123, "workflow.update"); + const deleteResult = await isAuthorized(workflow, 123, "workflow.delete"); + + expect(readResult).toBe(true); + expect(updateResult).toBe(true); + expect(deleteResult).toBe(true); + expect(mockPermissionCheckService).not.toHaveBeenCalled(); + }); + }); + + describe("team workflows with PBAC", () => { + describe("read operations", () => { + it("should use workflow.read permission by default with all roles as fallback", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(true); + + const result = await isAuthorized(workflow, 123); + + expect(result).toBe(true); + expect(mockPermissionCheckService).toHaveBeenCalledTimes(1); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 123, + teamId: 456, + permission: "workflow.read", + fallbackRoles: ["ADMIN", "OWNER", "MEMBER"], + }); + }); + + it("should use workflow.read permission when explicitly passed", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(true); + + const result = await isAuthorized(workflow, 123, "workflow.read"); + + expect(result).toBe(true); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 123, + teamId: 456, + permission: "workflow.read", + fallbackRoles: ["ADMIN", "OWNER", "MEMBER"], + }); + }); + + it("should return false when PBAC denies read permission", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(false); + + const result = await isAuthorized(workflow, 123, "workflow.read"); + + expect(result).toBe(false); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 123, + teamId: 456, + permission: "workflow.read", + fallbackRoles: ["ADMIN", "OWNER", "MEMBER"], + }); + }); + }); + + describe("update operations", () => { + it("should use workflow.update permission with admin/owner roles as fallback", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(true); + + const result = await isAuthorized(workflow, 123, "workflow.update"); + + expect(result).toBe(true); + expect(mockPermissionCheckService).toHaveBeenCalledTimes(1); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 123, + teamId: 456, + permission: "workflow.update", + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + + it("should return false when PBAC denies update permission", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(false); + + const result = await isAuthorized(workflow, 123, "workflow.update"); + + expect(result).toBe(false); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 123, + teamId: 456, + permission: "workflow.update", + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + }); + + describe("delete operations", () => { + it("should use workflow.delete permission with admin/owner roles as fallback", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(true); + + const result = await isAuthorized(workflow, 123, "workflow.delete"); + + expect(result).toBe(true); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 123, + teamId: 456, + permission: "workflow.delete", + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + + it("should return false when PBAC denies delete permission", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(false); + + const result = await isAuthorized(workflow, 123, "workflow.delete"); + + expect(result).toBe(false); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 123, + teamId: 456, + permission: "workflow.delete", + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + }); + + describe("other permissions", () => { + it("should use workflow.create permission with admin/owner roles as fallback", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(true); + + const result = await isAuthorized(workflow, 123, "workflow.create"); + + expect(result).toBe(true); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 123, + teamId: 456, + permission: "workflow.create", + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + + it("should use workflow.manage permission with admin/owner roles as fallback", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(true); + + const result = await isAuthorized(workflow, 123, "workflow.manage"); + + expect(result).toBe(true); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 123, + teamId: 456, + permission: "workflow.manage", + fallbackRoles: ["ADMIN", "OWNER"], + }); + }); + }); + + describe("permission service integration", () => { + it("should create a new PermissionCheckService instance for each call", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(true); + + await isAuthorized(workflow, 123, "workflow.read"); + await isAuthorized(workflow, 123, "workflow.update"); + + expect(mockPermissionCheckService).toHaveBeenCalledTimes(2); + }); + + it("should handle permission service errors gracefully", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockRejectedValue(new Error("Permission service error")); + + await expect(isAuthorized(workflow, 123, "workflow.read")).rejects.toThrow( + "Permission service error" + ); + }); + }); + }); + + describe("edge cases", () => { + it("should handle workflow with teamId 0 as personal workflow", async () => { + const workflow = { + id: 1, + teamId: 0, + userId: 123, + }; + + const result = await isAuthorized(workflow, 123, "workflow.delete"); + + expect(result).toBe(true); + expect(mockPermissionCheckService).not.toHaveBeenCalled(); + }); + + it("should handle workflow with positive teamId as team workflow", async () => { + const workflow = { + id: 1, + teamId: 1, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(true); + + const result = await isAuthorized(workflow, 123, "workflow.read"); + + expect(result).toBe(true); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 123, + teamId: 1, + permission: "workflow.read", + fallbackRoles: ["ADMIN", "OWNER", "MEMBER"], + }); + }); + + it("should handle different user IDs correctly", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + }; + + mockCheckPermission.mockResolvedValue(true); + + const result = await isAuthorized(workflow, 789, "workflow.read"); + + expect(result).toBe(true); + expect(mockCheckPermission).toHaveBeenCalledWith({ + userId: 789, + teamId: 456, + permission: "workflow.read", + fallbackRoles: ["ADMIN", "OWNER", "MEMBER"], + }); + }); + + it("should handle workflow with undefined teamId as personal workflow", async () => { + const workflow = { + id: 1, + teamId: undefined as any, + userId: 123, + }; + + const result = await isAuthorized(workflow, 123, "workflow.delete"); + + expect(result).toBe(true); + expect(mockPermissionCheckService).not.toHaveBeenCalled(); + }); + }); + + describe("type safety", () => { + it("should work with minimal workflow object", async () => { + const workflow = { + id: 1, + teamId: null, + userId: 123, + }; + + const result = await isAuthorized(workflow, 123, "workflow.read"); + expect(result).toBe(true); + }); + + it("should work with workflow object containing extra properties", async () => { + const workflow = { + id: 1, + teamId: 456, + userId: 123, + name: "Test Workflow", + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockCheckPermission.mockResolvedValue(true); + + const result = await isAuthorized(workflow, 123, "workflow.update"); + expect(result).toBe(true); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/workflows/util.ts b/packages/trpc/server/routers/viewer/workflows/util.ts index 8fd7c42a564902..360c3f108ccda8 100644 --- a/packages/trpc/server/routers/viewer/workflows/util.ts +++ b/packages/trpc/server/routers/viewer/workflows/util.ts @@ -15,6 +15,8 @@ import { getSmsReminderNumberSource, } from "@calcom/features/bookings/lib/getBookingFields"; import { removeBookingField, upsertBookingField } from "@calcom/features/eventtypes/lib/bookingFieldsManager"; +import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { SENDER_ID, SENDER_NAME } from "@calcom/lib/constants"; import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server"; import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId"; @@ -229,62 +231,33 @@ export function getSender( export async function isAuthorized( workflow: Pick | null, currentUserId: number, - isWriteOperation?: boolean + permission: PermissionString = "workflow.read" ) { if (!workflow) { return false; } - if (!isWriteOperation) { - const userWorkflow = await prisma.workflow.findFirst({ - where: { - id: workflow.id, - OR: [ - { userId: currentUserId }, - { - // for read operation every team member has access - team: { - members: { - some: { - userId: currentUserId, - accepted: true, - }, - }, - }, - }, - ], - }, - }); - if (userWorkflow) return true; + + // For personal workflows (no teamId), check if user owns the workflow + if (!workflow.teamId) { + return workflow.userId === currentUserId; } - const userWorkflow = await prisma.workflow.findFirst({ - where: { - id: workflow.id, - OR: [ - { userId: currentUserId }, - { - team: { - members: { - some: { - userId: currentUserId, - accepted: true, - //only admins can update team/org workflows - NOT: { - role: MembershipRole.MEMBER, - }, - }, - }, - }, - }, - ], - }, - }); + // For team workflows, use PBAC + const permissionService = new PermissionCheckService(); - if (userWorkflow) return true; + // Determine fallback roles based on permission type + const fallbackRoles = + permission === "workflow.read" + ? [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER] + : [MembershipRole.ADMIN, MembershipRole.OWNER]; - return false; + return await permissionService.checkPermission({ + userId: currentUserId, + teamId: workflow.teamId, + permission, + fallbackRoles, + }); } - export async function upsertSmsReminderFieldForEventTypes({ activeOn, workflowId,