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 ade70cd4e26afc..7c261eac0c9d3f 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,5 +1,9 @@ import { CrudAction } from "@calcom/features/pbac/domain/types/permission-registry"; import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry"; +import { + getTransitiveDependencies, + getTransitiveDependents, +} from "@calcom/features/pbac/utils/permissionTraversal"; export type PermissionLevel = "none" | "read" | "all"; @@ -129,34 +133,22 @@ export function usePermissions(): UsePermissionsReturn { // Add the requested permission newPermissions.push(permission); - // If enabling create, update, or delete, automatically enable read permission - if (action === CrudAction.Create || action === CrudAction.Update || action === CrudAction.Delete) { - const readPermission = `${resource}.${CrudAction.Read}`; - if (!newPermissions.includes(readPermission)) { - newPermissions.push(readPermission); + // Add all transitive dependencies + const dependencies = getTransitiveDependencies(permission); + dependencies.forEach((dependency) => { + if (!newPermissions.includes(dependency)) { + newPermissions.push(dependency); } - } + }); } else { - // When disabling a permission, check if we need to disable related permissions - if (action === CrudAction.Read) { - // If disabling read, also disable create, update, and delete since they depend on read - const dependentActions = [CrudAction.Create, CrudAction.Update, CrudAction.Delete]; - dependentActions.forEach((dependentAction) => { - const dependentPermission = `${resource}.${dependentAction}`; - newPermissions = newPermissions.filter((p) => p !== dependentPermission); - }); - } else if ( - action === CrudAction.Create || - action === CrudAction.Update || - action === CrudAction.Delete - ) { - // If disabling create, update, or delete, just remove that specific permission - // Read permission remains enabled - newPermissions = newPermissions.filter((p) => p !== permission); - } else { - // For other actions (custom actions), just remove the specific permission - newPermissions = newPermissions.filter((p) => p !== permission); - } + // When disabling a permission, first remove the permission itself + newPermissions = newPermissions.filter((p) => p !== permission); + + // Remove all transitive dependents + const dependents = getTransitiveDependents(permission); + dependents.forEach((dependent) => { + newPermissions = newPermissions.filter((p) => p !== dependent); + }); } // Only add *.* back if all permissions are now selected diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 4c8ea3d76ededf..9ecd3039a50d21 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -41,6 +41,7 @@ export interface PermissionDetails { i18nKey: string; descriptionI18nKey: string; scope?: Scope[]; // Optional for backward compatibility + dependsOn?: PermissionString[]; // Dependencies that must be enabled when this permission is enabled } export type ResourceConfig = { @@ -122,6 +123,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "role", i18nKey: "pbac_action_create", descriptionI18nKey: "pbac_desc_create_roles", + dependsOn: ["role.read"], }, [CrudAction.Read]: { description: "View roles", @@ -134,12 +136,14 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "role", i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_update_roles", + dependsOn: ["role.read"], }, [CrudAction.Delete]: { description: "Delete roles", category: "role", i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_roles", + dependsOn: ["role.read"], }, }, [Resource.EventType]: { @@ -151,6 +155,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "event", i18nKey: "pbac_action_create", descriptionI18nKey: "pbac_desc_create_event_types", + dependsOn: ["eventType.read"], }, [CrudAction.Read]: { description: "View event types", @@ -163,12 +168,14 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "event", i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_update_event_types", + dependsOn: ["eventType.read"], }, [CrudAction.Delete]: { description: "Delete event types", category: "event", i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_event_types", + dependsOn: ["eventType.read"], }, }, [Resource.Team]: { @@ -181,6 +188,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_create", descriptionI18nKey: "pbac_desc_create_teams", scope: [Scope.Organization], + dependsOn: ["team.read"], }, [CrudAction.Read]: { description: "View team details", @@ -193,30 +201,35 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "team", i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_update_team_settings", + dependsOn: ["team.read"], }, [CrudAction.Delete]: { description: "Delete team", category: "team", i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_team", + dependsOn: ["team.read"], }, [CustomAction.Invite]: { description: "Invite team members", category: "team", i18nKey: "pbac_action_invite", descriptionI18nKey: "pbac_desc_invite_team_members", + dependsOn: ["team.read"], }, [CustomAction.Remove]: { description: "Remove team members", category: "team", i18nKey: "pbac_action_remove", descriptionI18nKey: "pbac_desc_remove_team_members", + dependsOn: ["team.read"], }, [CustomAction.ChangeMemberRole]: { description: "Change role of team members", category: "team", i18nKey: "pbac_action_change_member_role", descriptionI18nKey: "pbac_desc_change_team_member_role", + dependsOn: ["team.read"], }, }, [Resource.Organization]: { @@ -243,6 +256,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_list_members", descriptionI18nKey: "pbac_desc_list_organization_members", scope: [Scope.Organization], + dependsOn: ["organization.read"], }, [CustomAction.Invite]: { description: "Invite organization members", @@ -250,6 +264,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_invite", descriptionI18nKey: "pbac_desc_invite_organization_members", scope: [Scope.Organization], + dependsOn: ["organization.listMembers"], }, [CustomAction.Remove]: { description: "Remove organization members", @@ -257,6 +272,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_remove", descriptionI18nKey: "pbac_desc_remove_organization_members", scope: [Scope.Organization], + dependsOn: ["organization.listMembers"], }, [CustomAction.ManageBilling]: { description: "Manage organization billing", @@ -264,6 +280,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_manage_billing", descriptionI18nKey: "pbac_desc_manage_organization_billing", scope: [Scope.Organization], + dependsOn: ["organization.read"], }, [CustomAction.ChangeMemberRole]: { description: "Change role of team members", @@ -271,6 +288,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_change_member_role", descriptionI18nKey: "pbac_desc_change_organization_member_role", scope: [Scope.Organization], + dependsOn: ["organization.listMembers", "role.read"], }, [CrudAction.Update]: { description: "Edit organization settings", @@ -278,6 +296,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_edit_organization_settings", scope: [Scope.Organization], + dependsOn: ["organization.read"], }, }, [Resource.Booking]: { @@ -296,6 +315,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_read_team_bookings", descriptionI18nKey: "pbac_desc_view_team_bookings", scope: [Scope.Team], + dependsOn: ["booking.read"], }, [CustomAction.ReadOrgBookings]: { description: "View organization bookings", @@ -303,18 +323,21 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_read_org_bookings", descriptionI18nKey: "pbac_desc_view_organization_bookings", scope: [Scope.Organization], + dependsOn: ["booking.read"], }, [CustomAction.ReadRecordings]: { description: "View booking recordings", category: "booking", i18nKey: "pbac_action_read_recordings", descriptionI18nKey: "pbac_desc_view_booking_recordings", + dependsOn: ["booking.read"], }, [CrudAction.Update]: { description: "Update bookings", category: "booking", i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_update_bookings", + dependsOn: ["booking.read"], }, }, [Resource.Insights]: { @@ -337,6 +360,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "workflow", i18nKey: "pbac_action_create", descriptionI18nKey: "pbac_desc_create_workflows", + dependsOn: ["workflow.read"], }, [CrudAction.Read]: { description: "View workflows", @@ -349,12 +373,14 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "workflow", i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_update_workflows", + dependsOn: ["workflow.read"], }, [CrudAction.Delete]: { description: "Delete workflows", category: "workflow", i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_workflows", + dependsOn: ["workflow.read"], }, }, [Resource.Attributes]: { @@ -372,18 +398,21 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "attributes", i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_update_organization_attributes", + dependsOn: ["organization.attributes.read"], }, [CrudAction.Delete]: { description: "Delete organization attributes", category: "attributes", i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_organization_attributes", + dependsOn: ["organization.attributes.read"], }, [CrudAction.Create]: { description: "Create organization attributes", category: "attributes", i18nKey: "pbac_action_create", descriptionI18nKey: "pbac_desc_create_organization_attributes", + dependsOn: ["organization.attributes.read"], }, }, [Resource.RoutingForm]: { @@ -395,6 +424,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "routing", i18nKey: "pbac_action_create", descriptionI18nKey: "pbac_desc_create_routing_forms", + dependsOn: ["routingForm.read"], }, [CrudAction.Read]: { description: "View routing forms", @@ -407,12 +437,14 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "routing", i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_update_routing_forms", + dependsOn: ["routingForm.read"], }, [CrudAction.Delete]: { description: "Delete routing forms", category: "routing", i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_routing_forms", + dependsOn: ["routingForm.read"], }, }, }; diff --git a/packages/features/pbac/utils/permissionTraversal.ts b/packages/features/pbac/utils/permissionTraversal.ts new file mode 100644 index 00000000000000..e0acd23ea291b9 --- /dev/null +++ b/packages/features/pbac/utils/permissionTraversal.ts @@ -0,0 +1,124 @@ +import { CrudAction, type CustomAction, PERMISSION_REGISTRY } from "../domain/types/permission-registry"; + +/** + * Helper function to split permission string into resource and action + * Handles dotted resource names like "organization.attributes.read" + * @param permission Permission string (e.g., "organization.attributes.read") + * @returns Object with resource and action parts + */ +const splitPermission = (permission: string): { resource: string; action: string } => { + const lastDotIndex = permission.lastIndexOf("."); + if (lastDotIndex === -1) { + throw new Error(`Invalid permission format: ${permission}`); + } + + const resource = permission.substring(0, lastDotIndex); + const action = permission.substring(lastDotIndex + 1); + + return { resource, action }; +}; + +/** + * Generic permission graph traversal function using BFS + * @param startPermission The permission to start traversal from + * @param direction Whether to find dependencies or dependents + * @returns Array of related permissions (excluding the start permission itself) + */ +export const traversePermissions = ( + startPermission: string, + direction: "dependencies" | "dependents" +): string[] => { + const visited = new Set(); + const result = new Set(); + const queue: string[] = [startPermission]; + + while (queue.length > 0) { + const currentPermission = queue.shift(); + if (!currentPermission) { + break; + } + + if (visited.has(currentPermission)) { + continue; + } + visited.add(currentPermission); + + if (direction === "dependencies") { + // Find what the current permission depends on + const { resource, action } = splitPermission(currentPermission); + const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY]; + + if (resourceConfig && resourceConfig[action as CrudAction | CustomAction]) { + const permissionDetails = resourceConfig[action as CrudAction | CustomAction]; + if (permissionDetails?.dependsOn) { + permissionDetails.dependsOn.forEach((dependency: string) => { + if (!visited.has(dependency)) { + result.add(dependency); + queue.push(dependency); + } + }); + } + } + + // Backward compatibility: add read dependency for CRUD operations + if (action === CrudAction.Create || action === CrudAction.Update || action === CrudAction.Delete) { + const readPermission = `${resource}.${CrudAction.Read}`; + if (!visited.has(readPermission)) { + result.add(readPermission); + queue.push(readPermission); + } + } + } else { + // Find what depends on the current permission + Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => { + Object.entries(config).forEach(([action, details]) => { + if (action.startsWith("_")) return; // Skip internal keys + + const permissionDetails = details as any; + const candidatePermission = `${resource}.${action}`; + + // Check explicit dependencies + if (permissionDetails?.dependsOn?.includes(currentPermission)) { + if (!visited.has(candidatePermission)) { + result.add(candidatePermission); + queue.push(candidatePermission); + } + } + + // Backward compatibility: check CRUD dependencies on read + const { resource: currentResource, action: currentAction } = splitPermission(currentPermission); + if ( + currentAction === CrudAction.Read && + resource === currentResource && + (action === CrudAction.Create || action === CrudAction.Update || action === CrudAction.Delete) + ) { + if (!visited.has(candidatePermission)) { + result.add(candidatePermission); + queue.push(candidatePermission); + } + } + }); + }); + } + } + + return Array.from(result); +}; + +/** + * Get all permissions that the given permission transitively depends on + * @param permission The permission to find dependencies for + * @returns Array of dependency permissions + */ +export const getTransitiveDependencies = (permission: string): string[] => { + return traversePermissions(permission, "dependencies"); +}; + +/** + * Get all permissions that transitively depend on the given permission + * @param permission The permission to find dependents for + * @returns Array of dependent permissions + */ +export const getTransitiveDependents = (permission: string): string[] => { + return traversePermissions(permission, "dependents"); +};