feat: (PBAC) Introduce depends on permission registery (#23440)

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
sean-brydonandEunjae Lee authored and GitHub committed 2025-08-29 09:33:24 +00:00
1 parent b332d9897d
commit 18423e5805
3 files changed
+174 -26

No files matched your search

@@ -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
@@ -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"],
},
},
};
@@ -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<string>();
const result = new Set<string>();
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");
};