From 87478770304c971187fdea833b0c20fac3550e87 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Mon, 28 Jul 2025 10:38:08 +0100 Subject: [PATCH 1/3] add concept of manage permissions instea of fallback --- apps/web/public/static/locales/en/common.json | 94 ++++--- .../pbac/domain/types/permission-registry.ts | 29 ++ .../permission-check.service.test.ts | 259 +++++++++++++++++- .../pbac/services/permission-check.service.ts | 84 +++++- .../migration.sql | 24 ++ 5 files changed, 446 insertions(+), 44 deletions(-) create mode 100644 packages/prisma/migrations/20250728091301_add_manage_permissions_to_default_roles/migration.sql diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4829475ef1bba8..8eae126b041020 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3273,27 +3273,8 @@ "none": "None", "read_only": "Read only", "pbac_desc_all_actions_all_resources": "All actions on all resources", - "pbac_resource_all": "All Resources", - "pbac_resource_event_type": "Event Types", - "pbac_resource_team": "Teams", - "pbac_resource_organization": "Organization", - "pbac_resource_booking": "Bookings", - "pbac_resource_insights": "Insights", - "pbac_resource_role": "Roles", - "pbac_action_all": "All Actions", - "pbac_action_create": "Create", - "pbac_action_read": "View", - "pbac_action_update": "Edit", - "pbac_action_delete": "Delete", - "pbac_action_manage": "Manage", - "pbac_action_invite": "Invite", - "pbac_action_remove": "Remove", - "pbac_action_change_member_role": "Change Member Role", - "pbac_action_list_members": "List Members", - "pbac_action_manage_billing": "Manage Billing", - "pbac_action_read_team_bookings": "View Team Bookings", - "pbac_action_read_org_bookings": "View Organization Bookings", - "pbac_action_read_recordings": "View Recordings", + + "role_created_successfully": "Role created successfully", "role_updated_successfully": "Role updated successfully", "delete_role": "Delete role", @@ -3305,12 +3286,7 @@ "pbac_desc_view_roles": "View roles", "pbac_desc_update_roles": "Update roles", "pbac_desc_delete_roles": "Delete roles", - "pbac_desc_manage_roles": "All actions on roles", - "pbac_desc_create_event_types": "Create event types", - "pbac_desc_view_event_types": "View event types", - "pbac_desc_update_event_types": "Update event types", - "pbac_desc_delete_event_types": "Delete event types", - "pbac_desc_manage_event_types": "All actions on event types", + "pbac_desc_create_teams": "Create teams", "pbac_desc_view_team_details": "View team details", "pbac_desc_update_team_settings": "Update team settings", @@ -3318,24 +3294,24 @@ "pbac_desc_invite_team_members": "Invite team members", "pbac_desc_remove_team_members": "Remove team members", "pbac_desc_change_team_member_role": "Change role of team members", - "pbac_desc_manage_teams": "All actions on teams", + "pbac_desc_create_organization": "Create organization", "pbac_desc_view_organization_details": "View organization details", "pbac_desc_list_organization_members": "List organization members", "pbac_desc_invite_organization_members": "Invite organization members", "pbac_desc_remove_organization_members": "Remove organization members", - "pbac_desc_manage_organization_billing": "Manage organization billing", + "pbac_desc_change_organization_member_role": "Change role of organization members", "pbac_desc_edit_organization_settings": "Edit organization settings", - "pbac_desc_manage_organizations": "All actions on organizations", + "pbac_desc_view_bookings": "View bookings", "pbac_desc_view_team_bookings": "View team bookings", "pbac_desc_view_organization_bookings": "View organization bookings", "pbac_desc_view_booking_recordings": "View booking recordings", "pbac_desc_update_bookings": "Update bookings", - "pbac_desc_manage_bookings": "All actions on bookings", + "pbac_desc_view_team_insights": "View team insights", - "pbac_desc_manage_team_insights": "Manage team insights", + "read_permission_auto_enabled_tooltip": "Read permission is automatically enabled when creating, updating, or deleting a resource", "choose_restriction_schedule": "Add restriction schedule", "restriction_schedule_description": "Limit availability of hosts to only display slots that fall within a specified schedule.", @@ -3418,5 +3394,59 @@ "usage_based_expiration_description": "This link can be used for {{count}} booking", "usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings", "usage_based_expiration_description_plural": "This link can be used for {{count}} bookings", + "pbac_resource_all": "All Resources", + "pbac_resource_role": "Roles", + "pbac_resource_event_type": "Event Types", + "pbac_resource_team": "Teams", + "pbac_resource_organization": "Organizations", + "pbac_resource_booking": "Bookings", + "pbac_resource_insights": "Insights", + "pbac_action_create": "Create", + "pbac_action_read": "View", + "pbac_action_update": "Edit", + "pbac_action_delete": "Delete", + "pbac_action_manage": "Manage", + "pbac_action_invite": "Invite", + "pbac_action_remove": "Remove", + "pbac_action_change_member_role": "Change Member Role", + "pbac_action_list_members": "List Members", + "pbac_action_manage_billing": "Manage Billing", + "pbac_action_read_team_bookings": "View Team Bookings", + "pbac_action_read_org_bookings": "View Organization Bookings", + "pbac_action_read_recordings": "View Recordings", + "pbac_desc_all_actions_all_resources": "All actions on all resources", + "pbac_desc_create_roles": "Create new roles", + "pbac_desc_view_roles": "View existing roles", + "pbac_desc_update_roles": "Edit existing roles", + "pbac_desc_delete_roles": "Delete existing roles", + "pbac_desc_manage_roles": "All actions on roles across organization teams", + "pbac_desc_create_event_types": "Create new event types", + "pbac_desc_view_event_types": "View existing event types", + "pbac_desc_update_event_types": "Edit existing event types", + "pbac_desc_delete_event_types": "Delete existing event types", + "pbac_desc_manage_event_types": "All actions on event types across organization teams", + "pbac_desc_create_teams": "Create new teams", + "pbac_desc_view_team_details": "View team information and settings", + "pbac_desc_update_team_settings": "Edit team settings and configuration", + "pbac_desc_delete_team": "Delete teams", + "pbac_desc_invite_team_members": "Invite new members to teams", + "pbac_desc_remove_team_members": "Remove members from teams", + "pbac_desc_change_team_member_role": "Change roles of team members", + "pbac_desc_manage_team_members": "All actions on team members across organization teams", + "pbac_desc_create_organization": "Create new organizations", + "pbac_desc_view_organization_details": "View organization information and settings", + "pbac_desc_list_organization_members": "View list of organization members", + "pbac_desc_invite_organization_members": "Invite new members to organizations", + "pbac_desc_remove_organization_members": "Remove members from organizations", + "pbac_desc_manage_organization_billing": "Manage organization billing and subscriptions", + "pbac_desc_change_organization_member_role": "Change roles of organization members", + "pbac_desc_edit_organization_settings": "Edit organization settings and configuration", + "pbac_desc_view_bookings": "View booking information", + "pbac_desc_view_team_bookings": "View bookings for team events", + "pbac_desc_view_organization_bookings": "View bookings for organization events", + "pbac_desc_view_booking_recordings": "View and access booking recordings", + "pbac_desc_update_bookings": "Edit and modify bookings", + "pbac_desc_manage_bookings": "All actions on bookings across organization teams", + "pbac_desc_view_team_insights": "View team analytics and insights", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 689364d9e5ddb5..79b9169b9b77dd 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -14,6 +14,7 @@ export enum CrudAction { Read = "read", Update = "update", Delete = "delete", + Manage = "manage", } export enum CustomAction { @@ -138,6 +139,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_roles", }, + [CrudAction.Manage]: { + description: "Manage roles on all sub-teams", + category: "role", + i18nKey: "pbac_action_manage", + descriptionI18nKey: "pbac_desc_manage_roles", + scope: [Scope.Organization], // Only organizations should have "Manage" permissions + }, }, [Resource.EventType]: { _resource: { @@ -167,6 +175,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_delete", descriptionI18nKey: "pbac_desc_delete_event_types", }, + [CrudAction.Manage]: { + description: "Manage event types", + category: "event", + i18nKey: "pbac_action_manage", + descriptionI18nKey: "pbac_desc_manage_event_types", + scope: [Scope.Organization], // Only organizations should have "Manage" permissions + }, }, [Resource.Team]: { _resource: { @@ -215,6 +230,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_change_member_role", descriptionI18nKey: "pbac_desc_change_team_member_role", }, + [CrudAction.Manage]: { + description: "Manage team members", + category: "team", + i18nKey: "pbac_action_manage", + descriptionI18nKey: "pbac_desc_manage_team_members", + scope: [Scope.Organization], // Only organizations should have "Manage" permissions + }, }, [Resource.Organization]: { _resource: { @@ -313,6 +335,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { i18nKey: "pbac_action_update", descriptionI18nKey: "pbac_desc_update_bookings", }, + [CrudAction.Manage]: { + description: "Manage bookings", + category: "booking", + i18nKey: "pbac_action_manage", + descriptionI18nKey: "pbac_desc_manage_bookings", + scope: [Scope.Organization], // Only organizations should have "Manage" permissions + }, }, [Resource.Insights]: { _resource: { diff --git a/packages/features/pbac/services/__tests__/permission-check.service.test.ts b/packages/features/pbac/services/__tests__/permission-check.service.test.ts index c415a8dddd01e5..96e469ecf2373a 100644 --- a/packages/features/pbac/services/__tests__/permission-check.service.test.ts +++ b/packages/features/pbac/services/__tests__/permission-check.service.test.ts @@ -267,15 +267,6 @@ describe("PermissionCheckService", () => { (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); - mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ - id: membership.id, - teamId: membership.teamId, - userId: membership.userId, - customRoleId: membership.customRoleId, - team: { parentId: null }, - }); - mockRepository.getOrgMembership.mockResolvedValueOnce(null); - mockRepository.checkRolePermissions.mockResolvedValueOnce(false); const result = await service.checkPermissions({ userId: 1, @@ -285,7 +276,8 @@ describe("PermissionCheckService", () => { }); expect(result).toBe(false); - expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("admin_role", []); + // Should not call repository methods for empty permissions array (security measure) + expect(mockRepository.checkRolePermissions).not.toHaveBeenCalled(); }); }); @@ -632,5 +624,252 @@ describe("PermissionCheckService", () => { Resource.EventType ); }); + + it("should expand permissions when user has manage permission", async () => { + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({ + id: 1, + teamId: 1, + userId: 1, + customRoleId: "admin_role", + team: { parentId: null }, + }); + + // User has manage permission + mockRepository.getResourcePermissionsByRoleId.mockResolvedValueOnce(["manage", "read"]); + + const result = await service.getResourcePermissions({ + userId: 1, + teamId: 1, + resource: Resource.EventType, + }); + + // Should include all possible actions for eventType resource + expect(result).toContain("eventType.manage"); + expect(result).toContain("eventType.create"); + expect(result).toContain("eventType.read"); + expect(result).toContain("eventType.update"); + expect(result).toContain("eventType.delete"); + expect(result.length).toBeGreaterThan(2); // More than just manage and read + }); + + it("should expand permissions when user has manage permission at org level", async () => { + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({ + id: 1, + teamId: 1, + userId: 1, + customRoleId: "team_role", + team: { parentId: 2 }, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce({ + id: 2, + teamId: 2, + userId: 1, + customRoleId: "admin_role", + }); + + // Team has basic permissions, org has manage + mockRepository.getResourcePermissionsByRoleId + .mockResolvedValueOnce(["read"]) // team permissions + .mockResolvedValueOnce(["manage"]); // org permissions + + const result = await service.getResourcePermissions({ + userId: 1, + teamId: 1, + resource: Resource.Role, + }); + + // Should include all possible actions for role resource due to manage permission + expect(result).toContain("role.manage"); + expect(result).toContain("role.create"); + expect(result).toContain("role.read"); + expect(result).toContain("role.update"); + expect(result).toContain("role.delete"); + }); + }); + + describe("hasPermission with manage permissions", () => { + it("should return true when user has manage permission for the resource", async () => { + const membership = { + id: 1, + teamId: 1, + userId: 1, + accepted: true, + role: "ADMIN" as MembershipRole, + customRoleId: "admin_role", + disableImpersonation: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ + id: membership.id, + teamId: membership.teamId, + userId: membership.userId, + customRoleId: membership.customRoleId, + team: { parentId: null }, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce(null); + + // User doesn't have explicit permission but has manage permission + mockRepository.checkRolePermission + .mockResolvedValueOnce(false) // explicit permission check fails + .mockResolvedValueOnce(true); // manage permission check succeeds + + const result = await service.checkPermission({ + userId: 1, + teamId: 1, + permission: "eventType.create", + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toBe(true); + expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "eventType.create"); + expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "eventType.manage"); + }); + + it("should return true when user has manage permission at org level", async () => { + const membership = { + id: 1, + teamId: 1, + userId: 1, + accepted: true, + role: "MEMBER" as MembershipRole, + customRoleId: "member_role", + disableImpersonation: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ + id: membership.id, + teamId: membership.teamId, + userId: membership.userId, + customRoleId: membership.customRoleId, + team: { parentId: 2 }, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce({ + id: 2, + teamId: 2, + userId: 1, + customRoleId: "admin_role", + }); + + // Team level permissions fail, org level manage permission succeeds + mockRepository.checkRolePermission + .mockResolvedValueOnce(false) // team explicit permission + .mockResolvedValueOnce(false) // team manage permission + .mockResolvedValueOnce(true); // org manage permission + + const result = await service.checkPermission({ + userId: 1, + teamId: 1, + permission: "role.delete", + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toBe(true); + expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "role.manage"); + }); + }); + + describe("hasPermissions with manage permissions", () => { + it("should return true when user has manage permissions for all requested resources", async () => { + const membership = { + id: 1, + teamId: 1, + userId: 1, + accepted: true, + role: "ADMIN" as MembershipRole, + customRoleId: "admin_role", + disableImpersonation: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ + id: membership.id, + teamId: membership.teamId, + userId: membership.userId, + customRoleId: membership.customRoleId, + team: { parentId: null }, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce(null); + + // Explicit permissions check fails, but manage permissions succeed + mockRepository.checkRolePermissions.mockResolvedValueOnce(false); + mockRepository.checkRolePermission + .mockResolvedValueOnce(true) // eventType.manage + .mockResolvedValueOnce(true); // role.manage + + const result = await service.checkPermissions({ + userId: 1, + teamId: 1, + permissions: ["eventType.create", "eventType.update", "role.delete"], + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toBe(true); + expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "eventType.manage"); + expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "role.manage"); + }); + + it("should return false when user has manage permission for some but not all requested resources", async () => { + const membership = { + id: 1, + teamId: 1, + userId: 1, + accepted: true, + role: "ADMIN" as MembershipRole, + customRoleId: "admin_role", + disableImpersonation: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + (MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership); + mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true); + mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({ + id: membership.id, + teamId: membership.teamId, + userId: membership.userId, + customRoleId: membership.customRoleId, + team: { parentId: 2 }, + }); + mockRepository.getOrgMembership.mockResolvedValueOnce({ + id: 2, + teamId: 2, + userId: 1, + customRoleId: "admin_role", + }); + + // All explicit permission checks fail + mockRepository.checkRolePermissions + .mockResolvedValueOnce(false) // team permissions + .mockResolvedValueOnce(false); // org permissions + + // Has manage for eventType but not for booking + mockRepository.checkRolePermission + .mockResolvedValueOnce(true) // team eventType.manage + .mockResolvedValueOnce(false) // team booking.manage + .mockResolvedValueOnce(true) // org eventType.manage (duplicate check) + .mockResolvedValueOnce(false); // org booking.manage + + const result = await service.checkPermissions({ + userId: 1, + teamId: 1, + permissions: ["eventType.create", "booking.update"], + fallbackRoles: ["ADMIN", "OWNER"], + }); + + expect(result).toBe(false); + }); }); }); diff --git a/packages/features/pbac/services/permission-check.service.ts b/packages/features/pbac/services/permission-check.service.ts index c96120b2d3dd51..7656b189f93ff7 100644 --- a/packages/features/pbac/services/permission-check.service.ts +++ b/packages/features/pbac/services/permission-check.service.ts @@ -12,6 +12,7 @@ import type { CrudAction, CustomAction, } from "../domain/types/permission-registry"; +import { PERMISSION_REGISTRY, filterResourceConfig } from "../domain/types/permission-registry"; import { PermissionRepository } from "../infrastructure/repositories/PermissionRepository"; import { PermissionService } from "./permission.service"; @@ -79,6 +80,19 @@ export class PermissionCheckService { orgActions.forEach((action) => actions.add(action)); } + // Check if user has "manage" permission - if so, grant all actions for this resource + if (actions.has("manage" as CrudAction)) { + // Get all possible actions for this resource from the permission registry + const resourceConfig = PERMISSION_REGISTRY[resource]; + if (resourceConfig) { + const allActions = Object.keys(filterResourceConfig(resourceConfig)) as ( + | CrudAction + | CustomAction + )[]; + allActions.forEach((action) => actions.add(action)); + } + } + return Array.from(actions).map((action) => PermissionMapper.toPermissionString({ resource, action })); } catch (error) { this.logger.error(error); @@ -197,11 +211,22 @@ export class PermissionCheckService { permission ); if (hasTeamPermission) return true; + + // Check if user has manage permission for this resource + const [resource] = permission.split("."); + const managePermission = `${resource}.manage` as PermissionString; + const hasManagePermission = await this.repository.checkRolePermission( + membership.customRoleId, + managePermission + ); + if (hasManagePermission) return true; } // If no team permission, check org-level permissions if (orgMembership?.customRoleId) { - return this.repository.checkRolePermission(orgMembership.customRoleId, permission); + const [resource] = permission.split("."); + const managePermission = `${resource}.manage` as PermissionString; + return this.repository.checkRolePermission(orgMembership.customRoleId, managePermission); } return false; @@ -211,6 +236,11 @@ export class PermissionCheckService { * Internal method to check multiple permissions for a specific role */ private async hasPermissions(query: PermissionCheck, permissions: PermissionString[]): Promise { + // Return false for empty permissions array to prevent privilege escalation + if (permissions.length === 0) { + return false; + } + const { membership, orgMembership } = await this.getMembership(query); // First check team-level permissions @@ -220,11 +250,61 @@ export class PermissionCheckService { permissions ); if (hasTeamPermissions) return true; + + // Check if user has manage permissions for all requested resources + const resourcesWithManage = new Set(); + for (const permission of permissions) { + const [resource] = permission.split("."); + if (!resourcesWithManage.has(resource)) { + const managePermission = `${resource}.manage` as PermissionString; + const hasManagePermission = await this.repository.checkRolePermission( + membership.customRoleId, + managePermission + ); + if (hasManagePermission) { + resourcesWithManage.add(resource); + } + } + } + + // Check if all requested permissions are covered by manage permissions + const allPermissionsCovered = permissions.every((permission) => { + const [resource] = permission.split("."); + return resourcesWithManage.has(resource); + }); + + if (allPermissionsCovered) return true; } // If no team permissions, check org-level permissions if (orgMembership?.customRoleId) { - return this.repository.checkRolePermissions(orgMembership.customRoleId, permissions); + const hasOrgPermissions = await this.repository.checkRolePermissions( + orgMembership.customRoleId, + permissions + ); + if (hasOrgPermissions) return true; + + // Check if user has manage permissions for all requested resources at org level + const resourcesWithManage = new Set(); + for (const permission of permissions) { + const [resource] = permission.split("."); + if (!resourcesWithManage.has(resource)) { + const managePermission = `${resource}.manage` as PermissionString; + const hasManagePermission = await this.repository.checkRolePermission( + orgMembership.customRoleId, + managePermission + ); + if (hasManagePermission) { + resourcesWithManage.add(resource); + } + } + } + + // Check if all requested permissions are covered by manage permissions + return permissions.every((permission) => { + const [resource] = permission.split("."); + return resourcesWithManage.has(resource); + }); } return false; diff --git a/packages/prisma/migrations/20250728091301_add_manage_permissions_to_default_roles/migration.sql b/packages/prisma/migrations/20250728091301_add_manage_permissions_to_default_roles/migration.sql new file mode 100644 index 00000000000000..865bb1cf6fa3db --- /dev/null +++ b/packages/prisma/migrations/20250728091301_add_manage_permissions_to_default_roles/migration.sql @@ -0,0 +1,24 @@ +-- Add manage permissions to admin and owner roles +-- These permissions are for organization-level management capabilities + +-- Insert manage permissions for admin role +INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt") +SELECT + gen_random_uuid(), 'admin_role', resource, action, NOW() +FROM ( + VALUES + -- Role management permissions (organization scope) + ('role', 'manage'), + + -- Event Type management permissions (organization scope) + ('eventType', 'manage'), + + -- Team management permissions (organization scope) + ('team', 'manage'), + + -- Booking management permissions (organization scope) + ('booking', 'manage') +) AS permissions(resource, action) +ON CONFLICT ("roleId", resource, action) DO NOTHING; + +-- Note: Owner role already has wildcard permissions (*.*) so it inherits all manage permissions automatically \ No newline at end of file From 50da7d7c1c49d1c5e2783a286d4677a8903e6d6e Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Mon, 28 Jul 2025 10:46:45 +0100 Subject: [PATCH 2/3] fix common json --- apps/web/public/static/locales/en/common.json | 94 +++++++------------ 1 file changed, 32 insertions(+), 62 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 8eae126b041020..11a55d364c781e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3273,8 +3273,27 @@ "none": "None", "read_only": "Read only", "pbac_desc_all_actions_all_resources": "All actions on all resources", - - + "pbac_resource_all": "All Resources", + "pbac_resource_event_type": "Event Types", + "pbac_resource_team": "Teams", + "pbac_resource_organization": "Organization", + "pbac_resource_booking": "Bookings", + "pbac_resource_insights": "Insights", + "pbac_resource_role": "Roles", + "pbac_action_all": "All Actions", + "pbac_action_create": "Create", + "pbac_action_read": "View", + "pbac_action_update": "Edit", + "pbac_action_delete": "Delete", + "pbac_action_manage": "Manage", + "pbac_action_invite": "Invite", + "pbac_action_remove": "Remove", + "pbac_action_change_member_role": "Change Member Role", + "pbac_action_list_members": "List Members", + "pbac_action_manage_billing": "Manage Billing", + "pbac_action_read_team_bookings": "View Team Bookings", + "pbac_action_read_org_bookings": "View Organization Bookings", + "pbac_action_read_recordings": "View Recordings", "role_created_successfully": "Role created successfully", "role_updated_successfully": "Role updated successfully", "delete_role": "Delete role", @@ -3286,7 +3305,12 @@ "pbac_desc_view_roles": "View roles", "pbac_desc_update_roles": "Update roles", "pbac_desc_delete_roles": "Delete roles", - + "pbac_desc_manage_roles": "All actions on roles across organization teams", + "pbac_desc_create_event_types": "Create event types", + "pbac_desc_view_event_types": "View event types", + "pbac_desc_update_event_types": "Update event types", + "pbac_desc_delete_event_types": "Delete event types", + "pbac_desc_manage_event_types": "All actions on event types across organization teams", "pbac_desc_create_teams": "Create teams", "pbac_desc_view_team_details": "View team details", "pbac_desc_update_team_settings": "Update team settings", @@ -3294,24 +3318,24 @@ "pbac_desc_invite_team_members": "Invite team members", "pbac_desc_remove_team_members": "Remove team members", "pbac_desc_change_team_member_role": "Change role of team members", - + "pbac_desc_manage_teams": "All actions on teams across organization teams", "pbac_desc_create_organization": "Create organization", "pbac_desc_view_organization_details": "View organization details", "pbac_desc_list_organization_members": "List organization members", "pbac_desc_invite_organization_members": "Invite organization members", "pbac_desc_remove_organization_members": "Remove organization members", - + "pbac_desc_manage_organization_billing": "Manage organization billing", "pbac_desc_change_organization_member_role": "Change role of organization members", "pbac_desc_edit_organization_settings": "Edit organization settings", - + "pbac_desc_manage_organizations": "All actions on organizations", "pbac_desc_view_bookings": "View bookings", "pbac_desc_view_team_bookings": "View team bookings", "pbac_desc_view_organization_bookings": "View organization bookings", "pbac_desc_view_booking_recordings": "View booking recordings", "pbac_desc_update_bookings": "Update bookings", - + "pbac_desc_manage_bookings": "All actions on bookings across organization teams", "pbac_desc_view_team_insights": "View team insights", - + "pbac_desc_manage_team_insights": "Manage team insights", "read_permission_auto_enabled_tooltip": "Read permission is automatically enabled when creating, updating, or deleting a resource", "choose_restriction_schedule": "Add restriction schedule", "restriction_schedule_description": "Limit availability of hosts to only display slots that fall within a specified schedule.", @@ -3394,59 +3418,5 @@ "usage_based_expiration_description": "This link can be used for {{count}} booking", "usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings", "usage_based_expiration_description_plural": "This link can be used for {{count}} bookings", - "pbac_resource_all": "All Resources", - "pbac_resource_role": "Roles", - "pbac_resource_event_type": "Event Types", - "pbac_resource_team": "Teams", - "pbac_resource_organization": "Organizations", - "pbac_resource_booking": "Bookings", - "pbac_resource_insights": "Insights", - "pbac_action_create": "Create", - "pbac_action_read": "View", - "pbac_action_update": "Edit", - "pbac_action_delete": "Delete", - "pbac_action_manage": "Manage", - "pbac_action_invite": "Invite", - "pbac_action_remove": "Remove", - "pbac_action_change_member_role": "Change Member Role", - "pbac_action_list_members": "List Members", - "pbac_action_manage_billing": "Manage Billing", - "pbac_action_read_team_bookings": "View Team Bookings", - "pbac_action_read_org_bookings": "View Organization Bookings", - "pbac_action_read_recordings": "View Recordings", - "pbac_desc_all_actions_all_resources": "All actions on all resources", - "pbac_desc_create_roles": "Create new roles", - "pbac_desc_view_roles": "View existing roles", - "pbac_desc_update_roles": "Edit existing roles", - "pbac_desc_delete_roles": "Delete existing roles", - "pbac_desc_manage_roles": "All actions on roles across organization teams", - "pbac_desc_create_event_types": "Create new event types", - "pbac_desc_view_event_types": "View existing event types", - "pbac_desc_update_event_types": "Edit existing event types", - "pbac_desc_delete_event_types": "Delete existing event types", - "pbac_desc_manage_event_types": "All actions on event types across organization teams", - "pbac_desc_create_teams": "Create new teams", - "pbac_desc_view_team_details": "View team information and settings", - "pbac_desc_update_team_settings": "Edit team settings and configuration", - "pbac_desc_delete_team": "Delete teams", - "pbac_desc_invite_team_members": "Invite new members to teams", - "pbac_desc_remove_team_members": "Remove members from teams", - "pbac_desc_change_team_member_role": "Change roles of team members", - "pbac_desc_manage_team_members": "All actions on team members across organization teams", - "pbac_desc_create_organization": "Create new organizations", - "pbac_desc_view_organization_details": "View organization information and settings", - "pbac_desc_list_organization_members": "View list of organization members", - "pbac_desc_invite_organization_members": "Invite new members to organizations", - "pbac_desc_remove_organization_members": "Remove members from organizations", - "pbac_desc_manage_organization_billing": "Manage organization billing and subscriptions", - "pbac_desc_change_organization_member_role": "Change roles of organization members", - "pbac_desc_edit_organization_settings": "Edit organization settings and configuration", - "pbac_desc_view_bookings": "View booking information", - "pbac_desc_view_team_bookings": "View bookings for team events", - "pbac_desc_view_organization_bookings": "View bookings for organization events", - "pbac_desc_view_booking_recordings": "View and access booking recordings", - "pbac_desc_update_bookings": "Edit and modify bookings", - "pbac_desc_manage_bookings": "All actions on bookings across organization teams", - "pbac_desc_view_team_insights": "View team analytics and insights", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } From 7055b1088a4461c08cf2a36d53125022fd557561 Mon Sep 17 00:00:00 2001 From: Sean Brydon Date: Mon, 28 Jul 2025 11:12:17 +0100 Subject: [PATCH 3/3] fix usePermission hook --- .../_components/__tests__/usePermissions.test.ts | 12 ++++++++++++ .../roles/_components/usePermissions.ts | 16 +++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/__tests__/usePermissions.test.ts b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/__tests__/usePermissions.test.ts index adeda4a091febe..723182ac4d6cbc 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/__tests__/usePermissions.test.ts +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/__tests__/usePermissions.test.ts @@ -44,5 +44,17 @@ describe("usePermissions", () => { expect(getResourcePermissionLevel("eventType", permissions)).toBe("all"); }); + + it("should return 'all' for resource with manage permission", () => { + const permissions = ["eventType.manage"]; + + expect(getResourcePermissionLevel("eventType", permissions)).toBe("all"); + }); + + it("should return 'all' for resource with manage permission even if other permissions are missing", () => { + const permissions = ["eventType.manage", "eventType.read"]; // Has manage and read, but missing create, update, delete + + expect(getResourcePermissionLevel("eventType", permissions)).toBe("all"); + }); }); }); 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..7200b79475ee17 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 @@ -51,14 +51,20 @@ export function usePermissions(): UsePermissionsReturn { return "all"; } - // Filter out internal keys like _resource when checking permissions - const allResourcePerms = Object.keys(resourceConfig) - .filter((action) => !action.startsWith("_")) + // Check if user has manage permission for this resource + const hasManagePermission = permissions.includes(`${resource}.manage`); + if (hasManagePermission) { + return "all"; + } + + // Filter out internal keys like _resource and manage when checking for individual permissions + const crudPermissions = Object.keys(resourceConfig) + .filter((action) => !action.startsWith("_") && action !== "manage") .map((action) => `${resource}.${action}`); - const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p)); + const hasAllCrudPerms = crudPermissions.every((p) => permissions.includes(p)); const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`); - if (hasAllPerms) return "all"; + if (hasAllCrudPerms) return "all"; if (hasReadPerm) return "read"; return "none"; };