Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
17e3735
feat: add Workflow resource to PBAC system with permission enforcement
devin-ai-integration[bot] Jul 31, 2025
de134d2
refactor: remove redundant workflow migration since permissions alrea…
devin-ai-integration[bot] Jul 31, 2025
abfa40b
fix: trigger fresh CI run to resolve GitHub status display issue
devin-ai-integration[bot] Jul 31, 2025
f634378
add isAuthorised PBAC - add new tests
sean-brydon Aug 5, 2025
49c52a9
update tests to use util function
sean-brydon Aug 5, 2025
7e7ce86
add workflow permissions to list + UI
sean-brydon Aug 5, 2025
11462f2
workflow readOnly on single get
sean-brydon Aug 5, 2025
95348c6
remove redudant props that were never used
sean-brydon Aug 5, 2025
58888f5
Restore Lock
sean-brydon Aug 5, 2025
21ec5f6
use invalidate instead of setFromData
sean-brydon Aug 5, 2025
e5c2a71
pass create permissions to new teams button
sean-brydon Aug 5, 2025
52a5c70
Update packages/features/ee/teams/components/createButton/CreateButto…
sean-brydon Aug 6, 2025
86741a1
pass permission to detail page
sean-brydon Aug 6, 2025
07aec7a
pass permission to detail page
sean-brydon Aug 6, 2025
b23799d
fix type error in workflow page.tsx
sean-brydon Aug 7, 2025
1b00abc
fix type error in workflow page.tsx
sean-brydon Aug 7, 2025
be805bd
Update packages/trpc/server/routers/loggedInViewer/teamsAndUserProfil…
sean-brydon Aug 7, 2025
e80209a
fix create personal when no teamId on workflows
sean-brydon Aug 7, 2025
e7523c6
filter based on canView
sean-brydon Aug 7, 2025
d0b472b
revert yarn.lock changes
Aug 7, 2025
dcd1f60
Merge branch 'main' into devin/1753972318-add-workflows-pbac-resource
CarinaWolli Aug 7, 2025
c9a4662
Merge branch 'main' into devin/1753972318-add-workflows-pbac-resource
sean-brydon Aug 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,10 +13,20 @@ export function CreateButtonWithTeamsList(
onlyShowWithNoTeams?: boolean;
isAdmin?: boolean;
includeOrg?: boolean;
withPermission?: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This button does't show the teams the user has access to:
Screenshot 2025-08-06 at 1 24 30 PM

This one does:
Screenshot 2025-08-06 at 1 25 18 PM

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;

Expand Down
30 changes: 28 additions & 2 deletions packages/features/ee/workflows/components/WorkflowDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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"
Expand Down
66 changes: 41 additions & 25 deletions packages/features/ee/workflows/components/WorkflowListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This user has no permission to see the team's workflows, but they are still shown as "readonly":
Screenshot 2025-08-07 at 12 33 02 PM

When clicking on the workflow to see the details it throws unauthorized

import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { ArrowButton } from "@calcom/ui/components/arrow-button";
Expand Down Expand Up @@ -52,7 +53,8 @@ export type WorkflowType = Workflow & {
};
};
}[];
readOnly?: boolean;
readOnly?: boolean; // Keep for backward compatibility
permissions?: WorkflowPermissions;
isOrg?: boolean;
};
interface Props {
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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"
/>
Expand All @@ -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)) && (
Comment on lines +286 to +288
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify dropdown visibility logic for edge cases.

The dropdown visibility condition is complex and should be tested to ensure it behaves correctly in all scenarios:

  • When permissions exist but both canUpdate and canDelete are false
  • When permissions don't exist and readOnly is true
  • When permissions don't exist and readOnly is false/undefined

Let me verify the logic by checking if there are any test cases that cover these scenarios:


🏁 Script executed:

#!/bin/bash
# Description: Search for test files that cover the WorkflowListPage dropdown logic

# Find test files related to WorkflowListPage
fd -e test.tsx -e test.ts -e spec.tsx -e spec.ts | xargs rg -l "WorkflowListPage"

# Search for existing tests that verify dropdown behavior
rg -A 10 -B 5 "dropdown.*visible|visible.*dropdown" --type ts --type tsx

# Look for permission-related test cases
rg -A 10 -B 5 "permissions.*canUpdate|permissions.*canDelete" --type ts --type tsx

Length of output: 297


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate all .spec.ts/.spec.tsx/.test.ts/.test.tsx files in the repo
test_files=$(fd --type f --extension ts --extension tsx --regex '.*\.(spec|test)\.(ts|tsx)$')
if [ -z "$test_files" ]; then
  echo "No test/spec files found."
  exit 0
fi

echo ">> Test files found:"
echo "$test_files"
echo

echo ">> Searching for WorkflowListPage references in test files..."
echo "$test_files" | xargs -r rg -n "WorkflowListPage" || true
echo

echo ">> Searching for dropdown usage in test files..."
echo "$test_files" | xargs -r rg -n "dropdown" || true
echo

echo ">> Searching for permissions checks (canUpdate|canDelete) in test files..."
echo "$test_files" | xargs -r rg -n "permissions.*canUpdate|permissions.*canDelete" || true

Length of output: 149105


Add coverage for WorkflowListPage dropdown logic

The conditional around the dropdown in WorkflowListPage isn’t covered by any existing tests. Please add unit tests for the following scenarios against
packages/features/ee/workflows/components/WorkflowListPage.tsx (around lines 286–288):

{(workflow.permissions?.canUpdate ||
  workflow.permissions?.canDelete ||
  (!workflow.permissions && !workflow.readOnly)) && (

Test cases to add:

  • permissions object exists but both canUpdate and canDelete are false → dropdown should not render
  • no permissions object and readOnly is true → dropdown should not render
  • no permissions object and readOnly is false or undefined → dropdown should render
🤖 Prompt for AI Agents
In packages/features/ee/workflows/components/WorkflowListPage.tsx around lines
286 to 288, add unit tests covering the dropdown rendering logic based on
workflow permissions and readOnly status. Specifically, create tests for these
scenarios: when permissions exist but both canUpdate and canDelete are false,
verify the dropdown does not render; when permissions are absent and readOnly is
true, verify the dropdown does not render; and when permissions are absent and
readOnly is false or undefined, verify the dropdown renders. Implement these
tests to ensure the conditional rendering is properly validated.

<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>
Expand Down
8 changes: 8 additions & 0 deletions packages/features/ee/workflows/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ function WorkflowsPage({ filteredList }: PageProps) {
disableMobileButton={true}
onlyShowWithNoTeams={true}
includeOrg={true}
withPermission={{
permission: "workflow.create",
fallbackRoles: ["ADMIN", "OWNER"],
}}
/>
) : null
}>
Expand All @@ -99,6 +103,10 @@ function WorkflowsPage({ filteredList }: PageProps) {
disableMobileButton={true}
onlyShowWithTeams={true}
includeOrg={true}
withPermission={{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user should still be able to create individual workflows even when the team doesn't allow it

permission: "workflow.create",
fallbackRoles: ["ADMIN", "OWNER"],
}}
/>
</div>
</div>
Expand Down
52 changes: 15 additions & 37 deletions packages/features/ee/workflows/pages/workflow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();

Expand All @@ -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;

Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -348,6 +325,7 @@ function WorkflowPage({
{isAllDataLoaded && user ? (
<>
<WorkflowDetailsPage
permissions={workflow?.permissions}
form={form}
workflowId={+workflowId}
user={user}
Expand Down
37 changes: 37 additions & 0 deletions packages/features/pbac/domain/types/permission-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export enum Resource {
Booking = "booking",
Insights = "insights",
Role = "role",
Workflow = "workflow",
}

export enum CrudAction {
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading