Skip to content

Commit 76ced2d

Browse files
sean-brydoncoderabbitai[bot]keithwillcodedevin-ai-integration[bot]
authored
feat: event type pbac (calcom#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 (calcom#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 <Sean@brydon.io> * 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 <keithwillcode@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent eaa1ad6 commit 76ced2d

31 files changed

Lines changed: 1975 additions & 301 deletions

apps/web/app/(use-page-wrapper)/event-types/[type]/page.tsx

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { z } from "zod";
88

99
import { EventTypeWebWrapper } from "@calcom/atoms/event-types/wrappers/EventTypeWebWrapper";
1010
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
11+
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
12+
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
13+
import { prisma } from "@calcom/prisma";
14+
import { MembershipRole } from "@calcom/prisma/enums";
1115
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
1216

1317
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -40,6 +44,95 @@ const getCachedEventType = unstable_cache(
4044
{ revalidate: 3600 } // Cache for 1 hour
4145
);
4246

47+
const getEventPermissions = async (userId: number, teamId: number | null) => {
48+
// Personal event - has all perms
49+
if (!teamId)
50+
return {
51+
eventTypes: {
52+
canRead: true,
53+
canCreate: true,
54+
canUpdate: true,
55+
canDelete: true,
56+
},
57+
workflows: {
58+
canRead: true,
59+
canCreate: true,
60+
canUpdate: true,
61+
canDelete: true,
62+
},
63+
};
64+
65+
const membership = await prisma.membership.findFirst({
66+
where: {
67+
userId,
68+
teamId,
69+
},
70+
select: {
71+
role: true,
72+
},
73+
});
74+
75+
if (!membership) throw new Error("Membership not found");
76+
77+
const [eventTypePermissions, workflowPermissions] = await Promise.all([
78+
getResourcePermissions({
79+
userId,
80+
teamId,
81+
resource: Resource.EventType,
82+
userRole: membership.role,
83+
fallbackRoles: {
84+
read: {
85+
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
86+
},
87+
update: {
88+
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
89+
},
90+
delete: {
91+
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
92+
},
93+
create: {
94+
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
95+
},
96+
},
97+
}),
98+
getResourcePermissions({
99+
userId,
100+
teamId,
101+
resource: Resource.Workflow,
102+
userRole: membership.role,
103+
fallbackRoles: {
104+
read: {
105+
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
106+
},
107+
update: {
108+
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
109+
},
110+
delete: {
111+
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
112+
},
113+
create: {
114+
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
115+
},
116+
},
117+
}),
118+
]);
119+
120+
return {
121+
eventTypes: {
122+
canRead: eventTypePermissions.canRead,
123+
canCreate: eventTypePermissions.canCreate,
124+
canUpdate: eventTypePermissions.canEdit,
125+
canDelete: eventTypePermissions.canDelete,
126+
},
127+
workflows: {
128+
canRead: workflowPermissions.canRead,
129+
canCreate: workflowPermissions.canCreate,
130+
canUpdate: workflowPermissions.canEdit,
131+
canDelete: workflowPermissions.canDelete,
132+
},
133+
};
134+
};
135+
43136
const ServerPage = async ({ params }: PageProps) => {
44137
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
45138
if (!session?.user?.id) {
@@ -59,7 +152,10 @@ const ServerPage = async ({ params }: PageProps) => {
59152
throw new Error("This event type does not exist");
60153
}
61154

62-
return <EventTypeWebWrapper data={data} id={eventTypeId} />;
155+
// Fetch permissions for the event type's team
156+
const permissions = await getEventPermissions(session.user.id, data.eventType.teamId);
157+
158+
return <EventTypeWebWrapper data={data} id={eventTypeId} permissions={permissions} />;
63159
};
64160

65161
export default ServerPage;

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,17 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
107107

108108
const { isAdvancedMode, permissions, color } = form.watch();
109109

110-
const filteredResources = useMemo(() => {
110+
const { filteredResources, scopedRegistry } = useMemo(() => {
111111
const scopedRegistry = getPermissionsForScope(scope);
112-
return Object.keys(scopedRegistry).filter((resource) =>
112+
const filteredResources = Object.keys(scopedRegistry).filter((resource) =>
113113
t(
114114
scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]]
115115
?.i18nKey || ""
116116
)
117117
.toLowerCase()
118118
.includes(searchQuery.toLowerCase())
119119
);
120+
return { filteredResources, scopedRegistry };
120121
}, [searchQuery, t, scope]);
121122

122123
const createMutation = trpc.viewer.pbac.createRole.useMutation({
@@ -212,7 +213,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
212213
disabled={isSystemRole}
213214
/>
214215
</div>
215-
216216
{filteredResources.map((resource) => (
217217
<AdvancedPermissionGroup
218218
key={resource}
@@ -222,7 +222,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
222222
disabled={isSystemRole}
223223
scope={scope}
224224
/>
225-
))}
225+
))}{" "}
226226
</div>
227227
) : (
228228
<div className="bg-muted rounded-xl p-1">
@@ -237,7 +237,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
237237
</div>
238238
</div>
239239
<div className="bg-default border-subtle divide-subtle divide-y rounded-[10px] border">
240-
{Object.keys(getPermissionsForScope(scope)).map((resource) => (
240+
{Object.keys(scopedRegistry).map((resource) => (
241241
<SimplePermissionItem
242242
key={resource}
243243
resource={resource}
@@ -247,7 +247,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
247247
scope={scope}
248248
/>
249249
))}
250-
</div>
250+
</div>{" "}
251251
</div>
252252
)}
253253
</div>

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
"use client";
22

3-
import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
4-
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
3+
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
4+
import {
5+
Scope,
6+
PERMISSION_REGISTRY,
7+
getPermissionsForScope,
8+
} from "@calcom/features/pbac/domain/types/permission-registry";
59
import { useLocale } from "@calcom/lib/hooks/useLocale";
610
import { ToggleGroup } from "@calcom/ui/components/form";
711

@@ -21,11 +25,13 @@ export function SimplePermissionItem({
2125
permissions,
2226
onChange,
2327
disabled,
24-
scope,
28+
scope = Scope.Organization,
2529
}: SimplePermissionItemProps) {
2630
const { t } = useLocale();
2731
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
32+
const scopedRegistry = getPermissionsForScope(scope);
2833

34+
const registry = scopedRegistry || PERMISSION_REGISTRY;
2935
const isAllResources = resource === "*";
3036
const options = isAllResources
3137
? [
@@ -41,7 +47,7 @@ export function SimplePermissionItem({
4147
return (
4248
<div className="flex items-center justify-between px-3 py-2">
4349
<span className="text-default text-sm font-medium leading-none">
44-
{t(PERMISSION_REGISTRY[resource as Resource]._resource?.i18nKey || resource)}
50+
{t(registry[resource as Resource]._resource?.i18nKey || resource)}
4551
</span>
4652
<ToggleGroup
4753
onValueChange={(val) => {

apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/usePermissions.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
2-
import {
3-
PERMISSION_REGISTRY,
4-
getPermissionsForScope,
5-
} from "@calcom/features/pbac/domain/types/permission-registry";
2+
import { getPermissionsForScope } from "@calcom/features/pbac/domain/types/permission-registry";
63
import {
74
getTransitiveDependencies,
85
getTransitiveDependents,
@@ -52,7 +49,8 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
5249
return permissions.includes("*.*") ? "all" : "none";
5350
}
5451

55-
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
52+
const scopedRegistry = getPermissionsForScope(scope);
53+
const resourceConfig = scopedRegistry[resource as keyof typeof scopedRegistry];
5654
if (!resourceConfig) return "none";
5755

5856
// Check if global all permissions (*.*) is present
@@ -111,7 +109,19 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
111109
allResourcePerms = Object.keys(resourceConfig)
112110
.filter((action) => !action.startsWith("_"))
113111
.map((action) => `${resource}.${action}`);
112+
113+
// Add the resource permissions
114114
newPermissions.push(...allResourcePerms);
115+
116+
// Add all transitive dependencies for each permission
117+
allResourcePerms.forEach((perm) => {
118+
const dependencies = getTransitiveDependencies(perm, scope);
119+
dependencies.forEach((dep) => {
120+
if (!newPermissions.includes(dep)) {
121+
newPermissions.push(dep);
122+
}
123+
});
124+
});
115125
break;
116126
}
117127

@@ -132,15 +142,12 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
132142
// First, remove *.* since we're modifying individual permissions
133143
let newPermissions = currentPermissions.filter((p) => p !== "*.*");
134144

135-
// Parse the permission to get resource and action
136-
const [resource, action] = permission.split(".");
137-
138145
if (enabled) {
139146
// Add the requested permission
140147
newPermissions.push(permission);
141148

142149
// Add all transitive dependencies
143-
const dependencies = getTransitiveDependencies(permission);
150+
const dependencies = getTransitiveDependencies(permission, scope);
144151
dependencies.forEach((dependency) => {
145152
if (!newPermissions.includes(dependency)) {
146153
newPermissions.push(dependency);
@@ -151,7 +158,7 @@ export function usePermissions(scope: Scope = Scope.Organization): UsePermission
151158
newPermissions = newPermissions.filter((p) => p !== permission);
152159

153160
// Remove all transitive dependents
154-
const dependents = getTransitiveDependents(permission);
161+
const dependents = getTransitiveDependents(permission, scope);
155162
dependents.forEach((dependent) => {
156163
newPermissions = newPermissions.filter((p) => p !== dependent);
157164
});

apps/web/modules/event-types/views/event-types-listing-view.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { useTypedQuery } from "@calcom/lib/hooks/useTypedQuery";
2727
import { HttpError } from "@calcom/lib/http-error";
2828
import { parseEventTypeColor } from "@calcom/lib/isEventTypeColor";
2929
import { localStorage } from "@calcom/lib/webstorage";
30-
import type { MembershipRole } from "@calcom/prisma/enums";
30+
import { MembershipRole } from "@calcom/prisma/enums";
3131
import { SchedulingType } from "@calcom/prisma/enums";
3232
import type { RouterOutputs } from "@calcom/trpc/react";
3333
import { trpc } from "@calcom/trpc/react";
@@ -954,6 +954,24 @@ export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
954954
userEventGroupsData?.profiles
955955
?.filter((profile) => !profile.readOnly)
956956
?.filter((profile) => !profile.eventTypesLockedByOrg)
957+
?.filter((profile) => {
958+
// For personal profiles (teamId is null), always allow creation
959+
if (!profile.teamId) {
960+
return true;
961+
}
962+
963+
// For team profiles, check if user has eventType.create permission
964+
// This will be populated by the server-side PBAC check
965+
// Fallback to role-based check (admin/owner) if canCreateEventTypes is not set
966+
if (profile.canCreateEventTypes !== undefined) {
967+
return profile.canCreateEventTypes;
968+
}
969+
970+
// Fallback: allow admin and owner roles
971+
return (
972+
profile.membershipRole === MembershipRole.ADMIN || profile.membershipRole === MembershipRole.OWNER
973+
);
974+
})
957975
?.map((profile) => {
958976
return {
959977
teamId: profile.teamId,

packages/features/eventtypes/components/tabs/workflows/EventWorkfowsTab.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ import { showToast } from "@calcom/ui/components/toast";
2525
import { Tooltip } from "@calcom/ui/components/tooltip";
2626
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
2727

28-
type PartialWorkflowType = Pick<WorkflowType, "name" | "activeOn" | "isOrg" | "steps" | "id" | "readOnly">;
28+
type PartialWorkflowType = Pick<
29+
WorkflowType,
30+
"name" | "activeOn" | "isOrg" | "steps" | "id" | "readOnly" | "permissions"
31+
>;
2932

3033
type ItemProps = {
3134
workflow: PartialWorkflowType;
@@ -42,14 +45,6 @@ const WorkflowListItem = (props: ItemProps) => {
4245
const { workflow, eventType, isActive } = props;
4346
const { t } = useLocale();
4447

45-
const [activeEventTypeIds, setActiveEventTypeIds] = useState(
46-
workflow.activeOn?.map((active) => {
47-
if (active.eventType) {
48-
return active.eventType.id;
49-
}
50-
}) ?? []
51-
);
52-
5348
const utils = trpc.useUtils();
5449

5550
const activateEventTypeMutation = trpc.viewer.workflows.activateEventType.useMutation({
@@ -137,7 +132,7 @@ const WorkflowListItem = (props: ItemProps) => {
137132
</div>
138133
</>
139134
</div>
140-
{!workflow.readOnly && (
135+
{workflow.permissions?.canUpdate && (
141136
<div className="flex-none">
142137
<Link href={`/workflows/${workflow.id}`} passHref={true} target="_blank">
143138
<Button type="button" color="minimal" className="mr-4" EndIcon="external-link">
@@ -162,7 +157,7 @@ const WorkflowListItem = (props: ItemProps) => {
162157
)}
163158
<Switch
164159
checked={isActive}
165-
disabled={workflow.readOnly}
160+
disabled={!workflow.permissions?.canUpdate}
166161
onCheckedChange={() => {
167162
activateEventTypeMutation.mutate({ workflowId: workflow.id, eventTypeId: eventType.id });
168163
}}
@@ -230,7 +225,7 @@ function EventWorkflowsTab(props: Props) {
230225

231226
const createMutation = trpc.viewer.workflows.create.useMutation({
232227
onSuccess: async ({ workflow }) => {
233-
await router.replace(`/workflows/${workflow.id}?eventTypeId=${eventType.id}`);
228+
router.replace(`/workflows/${workflow.id}?eventTypeId=${eventType.id}`);
234229
},
235230
onError: (err) => {
236231
if (err instanceof HttpError) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"use client";
2+
3+
import type { ReactNode } from "react";
4+
5+
import { useWorkflowPermission } from "../hooks/useEventPermission";
6+
7+
interface WorkflowTabPermissionGuardProps {
8+
children: ReactNode;
9+
fallback?: ReactNode;
10+
}
11+
12+
export const WorkflowTabPermissionGuard = ({
13+
children,
14+
fallback = null,
15+
}: WorkflowTabPermissionGuardProps) => {
16+
const { hasPermission } = useWorkflowPermission("workflow.read");
17+
18+
if (!hasPermission) {
19+
return <>{fallback}</>;
20+
}
21+
22+
return <>{children}</>;
23+
};

0 commit comments

Comments
 (0)