feat: (PBAC) Introduce depends on permission registery (#23440)
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
1 parent
b332d9897d
commit
18423e5805
3 files changed
+174
-26
No files matched your search
+18
-26
@@ -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");
|
||||
};
|
||||
Reference in new issue
Block a user