Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -3305,20 +3305,20 @@
"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_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",
"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",
"pbac_desc_delete_team": "Delete team",
"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_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",
Expand All @@ -3333,7 +3333,7 @@
"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_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",
Expand Down
29 changes: 29 additions & 0 deletions packages/features/pbac/domain/types/permission-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export enum CrudAction {
Read = "read",
Update = "update",
Delete = "delete",
Manage = "manage",
}

export enum CustomAction {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
});
});

Expand Down Expand Up @@ -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);
});
});
});
Loading
Loading