feat: add Workflow resource to PBAC system with permission enforcement (#22845)

Co-authored-by: sean@cal.com <Sean@brydon.io>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
This commit is contained in:
sean-brydon
2025-08-08 08:48:18 +00:00
committed by GitHub
co-authored by sean@cal.com <Sean@brydon.io> Carina Wollendorfer Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> CarinaWolli
parent d18e23378b
commit 3ea73e75d6
18 changed files with 820 additions and 139 deletions
@@ -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",
@@ -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;
@@ -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<FormValues>;
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) {
/>
</div>
<div className="md:border-subtle my-7 border-transparent md:border-t" />
{!props.readOnly && (
{permissions.canDelete && (
<Button
type="button"
StartIcon="trash-2"
@@ -6,6 +6,7 @@ import { useState } from "react";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WorkflowPermissions } from "@calcom/lib/server/repository/workflow-permissions";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { ArrowButton } from "@calcom/ui/components/arrow-button";
@@ -52,7 +53,8 @@ export type WorkflowType = Workflow & {
};
};
}[];
readOnly?: boolean;
readOnly?: boolean; // Keep for backward compatibility
permissions?: WorkflowPermissions;
isOrg?: boolean;
};
interface Props {
@@ -136,7 +138,7 @@ export default function WorkflowListPage({ workflows }: Props) {
: "Untitled"}
</div>
<div>
{workflow.readOnly && (
{(workflow.permissions?.readOnly ?? workflow.readOnly) && (
<Badge variant="gray" className="ml-2 ">
{t("readonly")}
</Badge>
@@ -257,7 +259,9 @@ export default function WorkflowListPage({ workflows }: Props) {
color="secondary"
variant="icon"
StartIcon="pencil"
disabled={workflow.readOnly}
disabled={
workflow.permissions ? !workflow.permissions.canUpdate : workflow.readOnly
}
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}
data-testid="edit-button"
/>
@@ -270,40 +274,52 @@ export default function WorkflowListPage({ workflows }: Props) {
}}
color="secondary"
variant="icon"
disabled={workflow.readOnly}
disabled={
workflow.permissions ? !workflow.permissions.canDelete : workflow.readOnly
}
StartIcon="trash-2"
data-testid="delete-button"
/>
</Tooltip>
</ButtonGroup>
</div>
{!workflow.readOnly && (
{(workflow.permissions?.canUpdate ||
workflow.permissions?.canDelete ||
(!workflow.permissions && !workflow.readOnly)) && (
<div className="block sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" color="minimal" variant="icon" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="pencil"
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon="trash-2"
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
{(workflow.permissions
? workflow.permissions.canUpdate
: !workflow.readOnly) && (
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon="pencil"
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{(workflow.permissions
? workflow.permissions.canDelete
: !workflow.readOnly) && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon="trash-2"
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
@@ -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"],
}}
/>
</div>
</div>
@@ -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<ReturnType<typeof WorkflowRepository.getById>>;
verifiedNumbers?: Awaited<ReturnType<typeof WorkflowRepository.getVerifiedNumbers>>;
verifiedEmails?: Awaited<ReturnType<typeof WorkflowRepository.getVerifiedEmails>>;
};
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 ? (
<>
<WorkflowDetailsPage
permissions={workflow?.permissions}
form={form}
workflowId={+workflowId}
user={user}
@@ -7,6 +7,7 @@ export enum Resource {
Booking = "booking",
Insights = "insights",
Role = "role",
Workflow = "workflow",
}
export enum CrudAction {
@@ -355,6 +356,42 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
descriptionI18nKey: "pbac_desc_view_team_insights",
},
},
[Resource.Workflow]: {
_resource: {
i18nKey: "pbac_resource_workflow",
},
[CrudAction.Create]: {
description: "Create workflows",
category: "workflow",
i18nKey: "pbac_action_create",
descriptionI18nKey: "pbac_desc_create_workflows",
},
[CrudAction.Read]: {
description: "View workflows",
category: "workflow",
i18nKey: "pbac_action_read",
descriptionI18nKey: "pbac_desc_view_workflows",
},
[CrudAction.Update]: {
description: "Update workflows",
category: "workflow",
i18nKey: "pbac_action_update",
descriptionI18nKey: "pbac_desc_update_workflows",
},
[CrudAction.Delete]: {
description: "Delete workflows",
category: "workflow",
i18nKey: "pbac_action_delete",
descriptionI18nKey: "pbac_desc_delete_workflows",
},
[CrudAction.Manage]: {
description: "Manage workflows",
category: "workflow",
i18nKey: "pbac_action_manage",
descriptionI18nKey: "pbac_desc_manage_workflows",
scope: [Scope.Organization],
},
},
[Resource.Attributes]: {
_resource: {
i18nKey: "pbac_resource_attributes",
@@ -0,0 +1,169 @@
import type { Workflow } from "@prisma/client";
import { isAuthorized } from "@calcom/trpc/server/routers/viewer/workflows/util";
export interface WorkflowPermissions {
canView: boolean;
canUpdate: boolean;
canDelete: boolean;
canManage: boolean;
readOnly: boolean; // Keep for backward compatibility
}
interface TeamPermissionsCache {
[teamId: string]: WorkflowPermissions;
}
export class WorkflowPermissionsBuilder {
private currentUserId: number;
private teamPermissionsCache: TeamPermissionsCache = {};
constructor(currentUserId: number) {
this.currentUserId = currentUserId;
}
/**
* Get permissions for a team (cached)
*/
private async getTeamPermissions(teamId: number): Promise<WorkflowPermissions> {
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<Workflow, "userId">): 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<Workflow, "id" | "teamId" | "userId"> | null
): Promise<WorkflowPermissions> {
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<T extends Pick<Workflow, "id" | "teamId" | "userId">>(
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<Workflow, "id" | "teamId" | "userId"> | null,
currentUserId: number
): Promise<WorkflowPermissions> {
const builder = new WorkflowPermissionsBuilder(currentUserId);
return await builder.buildPermissions(workflow);
}
/**
* Static method for batch processing
*/
static async buildPermissionsForWorkflows<T extends Pick<Workflow, "id" | "teamId" | "userId">>(
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<T extends Pick<Workflow, "id" | "teamId" | "userId">>(
workflow: T,
currentUserId: number
): Promise<T & { permissions: WorkflowPermissions; readOnly: boolean }> {
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<T extends Pick<Workflow, "id" | "teamId" | "userId">>(
workflows: T[],
currentUserId: number
): Promise<(T & { permissions: WorkflowPermissions; readOnly: boolean })[]> {
return await WorkflowPermissionsBuilder.buildPermissionsForWorkflows(workflows, currentUserId);
}
+2 -3
View File
@@ -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 };
@@ -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),
})),
];
};
@@ -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();
@@ -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",
});
@@ -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" });
@@ -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,
};
};
@@ -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;
};
@@ -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,
@@ -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<typeof vi.fn>;
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);
});
});
});
@@ -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<Workflow, "id" | "teamId" | "userId"> | 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();
// Determine fallback roles based on permission type
const fallbackRoles =
permission === "workflow.read"
? [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER]
: [MembershipRole.ADMIN, MembershipRole.OWNER];
return await permissionService.checkPermission({
userId: currentUserId,
teamId: workflow.teamId,
permission,
fallbackRoles,
});
if (userWorkflow) return true;
return false;
}
export async function upsertSmsReminderFieldForEventTypes({
activeOn,
workflowId,