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:
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
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user