From 76ced2d3b8caf453c8b83959dd037b68eb3cc450 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 17 Sep 2025 10:25:30 +0100 Subject: [PATCH] feat: event type pbac (#22618) * fix members page crash with pbac feature flag * invite member to org backend * update org permissions * feat: org profile update settings * org general page * remove redudant me call * privacy page * add attributes to pbac * dync + sso on organization permissions * add tests for resource-permission util * pass permissions to attributes\ * restore invite members * update org update and attribute backends * fix type errors * fix orgId * fix types attempt two * fix types attempt two * fix type error * show/hide team event types based on eventType.read permission * fix dupe string in i18n * fix tests * fix team-dsync * update session to use profile * use profile metadata to get orgRolew * fix dsync * fix typing * Event type create pbac check * fix the readonly permission check. WIP * refactor getUserEventTypes * fix test * add generate util for update * update router to use new generator function for pbac procedures * update transform utils * fix members being able to update on UI * fix allSettled * fix fallback permissions * fix permission logic * fix compare memebrship type error * fix unit test error * fix manage test error * fix nested permissions * filter based on canView permission in getUserEventGroups * Update packages/trpc/server/routers/viewer/eventTypes/utils/EventTypeGroupFilter.test.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix tests * fix tests * feat: Adding workflow permission checks inside of eventTypes (#23038) * add permission checks for workflows * wip getall active workflows * check eventType.update permission on activiating workflow on event type * pass permissions to context and use context in client * remove local parsePermissionString and use registery * Use new scoped function * default scope to orgs * use object values for workflow permissions * fix type errors * Fix permission traversal + fix all state caluclation * fix eventType group to use permissions.canRead * fix: update failing PBAC unit tests and resolve ESLint warnings - Fix getUserEventGroups test: MEMBER role should not have update permissions in fallback scenario - Fix EventTypeGroupFilter test: add missing canRead property to mock permissions map - Replace 'as any' type assertions with proper 'as unknown as' type assertions to resolve ESLint warnings Both tests now pass and align with PBAC permission model where MEMBER role has read-only access to event types. Co-Authored-By: sean@cal.com * add pbac to _heavy router * Merge main * restore versions * Parallelizable members query * fix nits * fix create * Restore lock file * Fix import from merge artifact --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Keith Williams Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../event-types/[type]/page.tsx | 98 ++++- .../roles/_components/RoleSheet.tsx | 12 +- .../_components/SimplePermissionItem.tsx | 14 +- .../roles/_components/usePermissions.ts | 27 +- .../views/event-types-listing-view.tsx | 20 +- .../tabs/workflows/EventWorkfowsTab.tsx | 19 +- .../components/WorkflowTabPermissionGuard.tsx | 23 ++ .../client/context/EventPermissionContext.tsx | 80 ++++ .../pbac/client/hooks/useEventPermission.ts | 51 +++ .../pbac/domain/types/permission-registry.ts | 4 +- .../pbac/utils/permissionTraversal.ts | 27 +- .../event-types/hooks/useTabsNavigations.tsx | 20 +- .../wrappers/EventTypeWebWrapper.tsx | 64 ++- .../__tests__/getUserEventGroups.test.ts | 374 ++++++++++++++++++ .../routers/viewer/eventTypes/_router.ts | 35 +- .../eventTypes/getUserEventGroups.handler.ts | 195 ++------- .../viewer/eventTypes/heavy/_router.ts | 46 +-- .../viewer/eventTypes/heavy/create.handler.ts | 70 +++- .../eventTypes/teamAccessUseCase.test.ts | 133 +++++++ .../viewer/eventTypes/teamAccessUseCase.ts | 47 +++ .../eventTypes/usecases/EventGroupBuilder.ts | 134 +++++++ .../usecases/ProfilePermissionProcessor.ts | 15 + .../server/routers/viewer/eventTypes/util.ts | 68 +++- .../utils/EventTypeGroupFilter.test.ts | 193 +++++++++ .../eventTypes/utils/EventTypeGroupFilter.ts | 124 ++++++ .../viewer/eventTypes/utils/filterUtils.ts | 49 +++ .../eventTypes/utils/permissionUtils.ts | 107 +++++ .../viewer/eventTypes/utils/transformUtils.ts | 145 +++++++ .../workflows/activateEventType.handler.ts | 19 +- .../getAllActiveWorkflows.handler.ts | 32 +- .../routers/viewer/workflows/list.handler.ts | 27 +- 31 files changed, 1973 insertions(+), 299 deletions(-) create mode 100644 packages/features/pbac/client/components/WorkflowTabPermissionGuard.tsx create mode 100644 packages/features/pbac/client/context/EventPermissionContext.tsx create mode 100644 packages/features/pbac/client/hooks/useEventPermission.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/__tests__/getUserEventGroups.test.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/teamAccessUseCase.test.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/teamAccessUseCase.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/usecases/EventGroupBuilder.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/usecases/ProfilePermissionProcessor.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/utils/EventTypeGroupFilter.test.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/utils/EventTypeGroupFilter.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/utils/filterUtils.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/utils/permissionUtils.ts create mode 100644 packages/trpc/server/routers/viewer/eventTypes/utils/transformUtils.ts diff --git a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx index 43f400905c..116c8ff97f 100644 --- a/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx @@ -8,6 +8,10 @@ import { z } from "zod"; import { EventTypeWebWrapper } from "@calcom/atoms/event-types/wrappers/EventTypeWebWrapper"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions"; +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -40,6 +44,95 @@ const getCachedEventType = unstable_cache( { revalidate: 3600 } // Cache for 1 hour ); +const getEventPermissions = async (userId: number, teamId: number | null) => { + // Personal event - has all perms + if (!teamId) + return { + eventTypes: { + canRead: true, + canCreate: true, + canUpdate: true, + canDelete: true, + }, + workflows: { + canRead: true, + canCreate: true, + canUpdate: true, + canDelete: true, + }, + }; + + const membership = await prisma.membership.findFirst({ + where: { + userId, + teamId, + }, + select: { + role: true, + }, + }); + + if (!membership) throw new Error("Membership not found"); + + const [eventTypePermissions, workflowPermissions] = await Promise.all([ + getResourcePermissions({ + userId, + teamId, + resource: Resource.EventType, + userRole: membership.role, + fallbackRoles: { + read: { + roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + delete: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + create: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }), + getResourcePermissions({ + userId, + teamId, + resource: Resource.Workflow, + userRole: membership.role, + fallbackRoles: { + read: { + roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }, + update: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + delete: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + create: { + roles: [MembershipRole.ADMIN, MembershipRole.OWNER], + }, + }, + }), + ]); + + return { + eventTypes: { + canRead: eventTypePermissions.canRead, + canCreate: eventTypePermissions.canCreate, + canUpdate: eventTypePermissions.canEdit, + canDelete: eventTypePermissions.canDelete, + }, + workflows: { + canRead: workflowPermissions.canRead, + canCreate: workflowPermissions.canCreate, + canUpdate: workflowPermissions.canEdit, + canDelete: workflowPermissions.canDelete, + }, + }; +}; + const ServerPage = async ({ params }: PageProps) => { const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); if (!session?.user?.id) { @@ -59,7 +152,10 @@ const ServerPage = async ({ params }: PageProps) => { throw new Error("This event type does not exist"); } - return ; + // Fetch permissions for the event type's team + const permissions = await getEventPermissions(session.user.id, data.eventType.teamId); + + return ; }; export default ServerPage; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx index df10530954..68360fdb30 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx @@ -107,9 +107,9 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga const { isAdvancedMode, permissions, color } = form.watch(); - const filteredResources = useMemo(() => { + const { filteredResources, scopedRegistry } = useMemo(() => { const scopedRegistry = getPermissionsForScope(scope); - return Object.keys(scopedRegistry).filter((resource) => + const filteredResources = Object.keys(scopedRegistry).filter((resource) => t( scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]] ?.i18nKey || "" @@ -117,6 +117,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga .toLowerCase() .includes(searchQuery.toLowerCase()) ); + return { filteredResources, scopedRegistry }; }, [searchQuery, t, scope]); const createMutation = trpc.viewer.pbac.createRole.useMutation({ @@ -212,7 +213,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga disabled={isSystemRole} /> - {filteredResources.map((resource) => ( - ))} + ))}{" "} ) : (
@@ -237,7 +237,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
- {Object.keys(getPermissionsForScope(scope)).map((resource) => ( + {Object.keys(scopedRegistry).map((resource) => ( ))} -
+ {" "} )} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx index 5ba1ec0d86..e658890c30 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx @@ -1,7 +1,11 @@ "use client"; -import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry"; -import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry"; +import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry"; +import { + Scope, + PERMISSION_REGISTRY, + getPermissionsForScope, +} from "@calcom/features/pbac/domain/types/permission-registry"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { ToggleGroup } from "@calcom/ui/components/form"; @@ -21,11 +25,13 @@ export function SimplePermissionItem({ permissions, onChange, disabled, - scope, + scope = Scope.Organization, }: SimplePermissionItemProps) { const { t } = useLocale(); const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope); + const scopedRegistry = getPermissionsForScope(scope); + const registry = scopedRegistry || PERMISSION_REGISTRY; const isAllResources = resource === "*"; const options = isAllResources ? [ @@ -41,7 +47,7 @@ export function SimplePermissionItem({ return (
- {t(PERMISSION_REGISTRY[resource as Resource]._resource?.i18nKey || resource)} + {t(registry[resource as Resource]._resource?.i18nKey || resource)} { diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts index d7d5c2ea36..176186a29f 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts @@ -1,8 +1,5 @@ import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry"; -import { - PERMISSION_REGISTRY, - getPermissionsForScope, -} from "@calcom/features/pbac/domain/types/permission-registry"; +import { getPermissionsForScope } from "@calcom/features/pbac/domain/types/permission-registry"; import { getTransitiveDependencies, getTransitiveDependents, @@ -52,7 +49,8 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission return permissions.includes("*.*") ? "all" : "none"; } - const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY]; + const scopedRegistry = getPermissionsForScope(scope); + const resourceConfig = scopedRegistry[resource as keyof typeof scopedRegistry]; if (!resourceConfig) return "none"; // Check if global all permissions (*.*) is present @@ -111,7 +109,19 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission allResourcePerms = Object.keys(resourceConfig) .filter((action) => !action.startsWith("_")) .map((action) => `${resource}.${action}`); + + // Add the resource permissions newPermissions.push(...allResourcePerms); + + // Add all transitive dependencies for each permission + allResourcePerms.forEach((perm) => { + const dependencies = getTransitiveDependencies(perm, scope); + dependencies.forEach((dep) => { + if (!newPermissions.includes(dep)) { + newPermissions.push(dep); + } + }); + }); break; } @@ -132,15 +142,12 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission // First, remove *.* since we're modifying individual permissions let newPermissions = currentPermissions.filter((p) => p !== "*.*"); - // Parse the permission to get resource and action - const [resource, action] = permission.split("."); - if (enabled) { // Add the requested permission newPermissions.push(permission); // Add all transitive dependencies - const dependencies = getTransitiveDependencies(permission); + const dependencies = getTransitiveDependencies(permission, scope); dependencies.forEach((dependency) => { if (!newPermissions.includes(dependency)) { newPermissions.push(dependency); @@ -151,7 +158,7 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission newPermissions = newPermissions.filter((p) => p !== permission); // Remove all transitive dependents - const dependents = getTransitiveDependents(permission); + const dependents = getTransitiveDependents(permission, scope); dependents.forEach((dependent) => { newPermissions = newPermissions.filter((p) => p !== dependent); }); diff --git a/apps/web/modules/event-types/views/event-types-listing-view.tsx b/apps/web/modules/event-types/views/event-types-listing-view.tsx index 6ca2619060..6fe670651a 100644 --- a/apps/web/modules/event-types/views/event-types-listing-view.tsx +++ b/apps/web/modules/event-types/views/event-types-listing-view.tsx @@ -27,7 +27,7 @@ import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery"; import { HttpError } from "@calcom/lib/http-error"; import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor"; import { localStorage } from "@calcom/lib/webstorage"; -import type { MembershipRole } from "@calcom/prisma/enums"; +import { MembershipRole } from "@calcom/prisma/enums"; import { SchedulingType } from "@calcom/prisma/enums"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; @@ -954,6 +954,24 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit) => { userEventGroupsData?.profiles ?.filter((profile) => !profile.readOnly) ?.filter((profile) => !profile.eventTypesLockedByOrg) + ?.filter((profile) => { + // For personal profiles (teamId is null), always allow creation + if (!profile.teamId) { + return true; + } + + // For team profiles, check if user has eventType.create permission + // This will be populated by the server-side PBAC check + // Fallback to role-based check (admin/owner) if canCreateEventTypes is not set + if (profile.canCreateEventTypes !== undefined) { + return profile.canCreateEventTypes; + } + + // Fallback: allow admin and owner roles + return ( + profile.membershipRole === MembershipRole.ADMIN || profile.membershipRole === MembershipRole.OWNER + ); + }) ?.map((profile) => { return { teamId: profile.teamId, diff --git a/packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx b/packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx index 87e27a2200..0cae9b1b9a 100644 --- a/packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx +++ b/packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx @@ -25,7 +25,10 @@ import { showToast } from "@calcom/ui/components/toast"; import { Tooltip } from "@calcom/ui/components/tooltip"; import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions"; -type PartialWorkflowType = Pick; +type PartialWorkflowType = Pick< + WorkflowType, + "name" | "activeOn" | "isOrg" | "steps" | "id" | "readOnly" | "permissions" +>; type ItemProps = { workflow: PartialWorkflowType; @@ -42,14 +45,6 @@ const WorkflowListItem = (props: ItemProps) => { const { workflow, eventType, isActive } = props; const { t } = useLocale(); - const [activeEventTypeIds, setActiveEventTypeIds] = useState( - workflow.activeOn?.map((active) => { - if (active.eventType) { - return active.eventType.id; - } - }) ?? [] - ); - const utils = trpc.useUtils(); const activateEventTypeMutation = trpc.viewer.workflows.activateEventType.useMutation({ @@ -137,7 +132,7 @@ const WorkflowListItem = (props: ItemProps) => {
- {!workflow.readOnly && ( + {workflow.permissions?.canUpdate && (