Skip to content

Commit 24ff362

Browse files
fix: fixes PBAC roles creation and update when adding Attributes (calcom#23443)
* wip * restore lock * remove debug * add tests + extract util * Update packages/features/pbac/services/permission.service.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix parse permission string * use correct permission strings in tests * use eventType instead of event * fix import --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent f1d1674 commit 24ff362

8 files changed

Lines changed: 207 additions & 39 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, it, expect } from "vitest";
2+
3+
import { CrudAction, CustomAction, Resource } from "../permission-registry";
4+
import { isValidPermissionString, parsePermissionString } from "../permission-registry";
5+
6+
describe("Permission Registry Utilities", () => {
7+
describe("parsePermissionString", () => {
8+
it("should parse simple permission strings correctly", () => {
9+
const result = parsePermissionString("eventType.create");
10+
expect(result.resource).toBe("eventType");
11+
expect(result.action).toBe("create");
12+
});
13+
14+
it("should parse nested resource permission strings correctly", () => {
15+
const result = parsePermissionString("organization.attributes.create");
16+
expect(result.resource).toBe("organization.attributes");
17+
expect(result.action).toBe("create");
18+
});
19+
20+
it("should handle wildcard permissions", () => {
21+
const result = parsePermissionString("*.create");
22+
expect(result.resource).toBe("*");
23+
expect(result.action).toBe("create");
24+
});
25+
26+
it("should handle custom actions", () => {
27+
const result = parsePermissionString("team.invite");
28+
expect(result.resource).toBe("team");
29+
expect(result.action).toBe("invite");
30+
});
31+
32+
it("should handle deeply nested resources", () => {
33+
const result = parsePermissionString("organization.attributes.nested.action");
34+
expect(result.resource).toBe("organization.attributes.nested");
35+
expect(result.action).toBe("action");
36+
});
37+
});
38+
39+
describe("isValidPermissionString", () => {
40+
it("should validate basic permission strings", () => {
41+
expect(isValidPermissionString("eventType.create")).toBe(true);
42+
expect(isValidPermissionString("team.read")).toBe(true);
43+
expect(isValidPermissionString("organization.update")).toBe(true);
44+
});
45+
46+
it("should validate nested resource permissions", () => {
47+
expect(isValidPermissionString("organization.attributes.create")).toBe(true);
48+
expect(isValidPermissionString("organization.attributes.read")).toBe(true);
49+
expect(isValidPermissionString("organization.attributes.update")).toBe(true);
50+
expect(isValidPermissionString("organization.attributes.delete")).toBe(true);
51+
});
52+
53+
it("should validate wildcard permissions", () => {
54+
expect(isValidPermissionString("*.create")).toBe(true);
55+
expect(isValidPermissionString("eventType.*")).toBe(true);
56+
expect(isValidPermissionString("*.*")).toBe(true);
57+
});
58+
59+
it("should validate custom actions", () => {
60+
expect(isValidPermissionString("team.invite")).toBe(true);
61+
expect(isValidPermissionString("team.remove")).toBe(true);
62+
expect(isValidPermissionString("team.changeMemberRole")).toBe(true);
63+
expect(isValidPermissionString("organization.manageBilling")).toBe(true);
64+
expect(isValidPermissionString("booking.readTeamBookings")).toBe(true);
65+
});
66+
67+
it("should validate _resource special case", () => {
68+
expect(isValidPermissionString("eventType._resource")).toBe(true);
69+
expect(isValidPermissionString("team._resource")).toBe(true);
70+
expect(isValidPermissionString("organization._resource")).toBe(true);
71+
});
72+
73+
it("should reject invalid resource names", () => {
74+
expect(isValidPermissionString("invalidResource.create")).toBe(false);
75+
expect(isValidPermissionString("unknown.read")).toBe(false);
76+
});
77+
78+
it("should reject invalid action names", () => {
79+
expect(isValidPermissionString("eventType.invalidAction")).toBe(false);
80+
expect(isValidPermissionString("team.unknownAction")).toBe(false);
81+
});
82+
83+
it("should reject malformed permission strings", () => {
84+
expect(isValidPermissionString("invalidformat")).toBe(false);
85+
expect(isValidPermissionString("")).toBe(false);
86+
expect(isValidPermissionString("eventType.")).toBe(false);
87+
expect(isValidPermissionString(".create")).toBe(false);
88+
});
89+
90+
it("should reject non-string values", () => {
91+
expect(isValidPermissionString(null)).toBe(false);
92+
expect(isValidPermissionString(undefined)).toBe(false);
93+
expect(isValidPermissionString(123)).toBe(false);
94+
expect(isValidPermissionString({})).toBe(false);
95+
expect(isValidPermissionString([])).toBe(false);
96+
});
97+
98+
it("should validate all CRUD actions", () => {
99+
Object.values(CrudAction).forEach((action) => {
100+
expect(isValidPermissionString(`eventType.${action}`)).toBe(true);
101+
});
102+
});
103+
104+
it("should validate all custom actions for appropriate resources", () => {
105+
expect(isValidPermissionString(`team.${CustomAction.Invite}`)).toBe(true);
106+
expect(isValidPermissionString(`team.${CustomAction.Remove}`)).toBe(true);
107+
expect(isValidPermissionString(`team.${CustomAction.ChangeMemberRole}`)).toBe(true);
108+
expect(isValidPermissionString(`organization.${CustomAction.ManageBilling}`)).toBe(true);
109+
expect(isValidPermissionString(`booking.${CustomAction.ReadTeamBookings}`)).toBe(true);
110+
});
111+
112+
it("should validate all resources", () => {
113+
Object.values(Resource).forEach((resource) => {
114+
expect(isValidPermissionString(`${resource}.read`)).toBe(true);
115+
});
116+
});
117+
});
118+
});

packages/features/pbac/domain/types/permission-registry.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,54 @@ export type PermissionRegistry = {
5858

5959
export type PermissionString = `${Resource}.${CrudAction | CustomAction}`;
6060

61+
/**
62+
* Parsed permission object containing resource and action parts
63+
*/
64+
export interface ParsedPermission {
65+
resource: string;
66+
action: string;
67+
}
68+
69+
/**
70+
* Parses a permission string into its resource and action components
71+
* @param permission The permission string to parse
72+
* @returns Parsed permission object with resource and action
73+
*/
74+
export const parsePermissionString = (permission: string): ParsedPermission => {
75+
const lastDotIndex = permission.lastIndexOf(".");
76+
const resource = permission.substring(0, lastDotIndex);
77+
const action = permission.substring(lastDotIndex + 1);
78+
return { resource, action };
79+
};
80+
81+
/**
82+
* Validates a permission string format
83+
* @param val The permission string to validate
84+
* @returns True if valid, false otherwise
85+
*/
86+
export const isValidPermissionString = (val: unknown): val is PermissionString => {
87+
if (typeof val !== "string") return false;
88+
89+
// Handle special case for _resource
90+
if (val.endsWith("._resource")) {
91+
const resourcePart = val.slice(0, -10); // Remove "._resource"
92+
return Object.values(Resource).includes(resourcePart as Resource);
93+
}
94+
95+
// Split by the last dot to handle nested resources like "organization.attributes.create"
96+
const lastDotIndex = val.lastIndexOf(".");
97+
if (lastDotIndex === -1) return false;
98+
99+
const { resource, action } = parsePermissionString(val);
100+
101+
const isValidResource = Object.values(Resource).includes(resource as Resource);
102+
const isValidAction =
103+
Object.values(CrudAction).includes(action as CrudAction) ||
104+
Object.values(CustomAction).includes(action as CustomAction);
105+
106+
return isValidResource && isValidAction;
107+
};
108+
61109
/**
62110
* Helper function to filter out the _resource property from a ResourceConfig
63111
* @param config The ResourceConfig to filter

packages/features/pbac/infrastructure/repositories/PermissionRepository.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { PermissionMapper } from "../../domain/mappers/PermissionMapper";
55
import type { TeamPermissions } from "../../domain/models/Permission";
66
import type { IPermissionRepository } from "../../domain/repositories/IPermissionRepository";
77
import type { CrudAction, CustomAction } from "../../domain/types/permission-registry";
8-
import { Resource, type PermissionString } from "../../domain/types/permission-registry";
8+
import {
9+
Resource,
10+
type PermissionString,
11+
parsePermissionString,
12+
} from "../../domain/types/permission-registry";
913

1014
export class PermissionRepository implements IPermissionRepository {
1115
private client: PrismaClientWithExtensions;
@@ -91,7 +95,7 @@ export class PermissionRepository implements IPermissionRepository {
9195
}
9296

9397
async checkRolePermission(roleId: string, permission: PermissionString): Promise<boolean> {
94-
const [resource, action] = permission.split(".");
98+
const { resource, action } = parsePermissionString(permission);
9599
const hasPermission = await this.client.rolePermission.findFirst({
96100
where: {
97101
roleId,
@@ -113,7 +117,7 @@ export class PermissionRepository implements IPermissionRepository {
113117
}
114118

115119
const permissionPairs = permissions.map((p) => {
116-
const [resource, action] = p.split(".");
120+
const { resource, action } = parsePermissionString(p);
117121
return { resource, action };
118122
});
119123
const resourceActions = permissionPairs.map((p) => [p.resource, p.action]);
@@ -207,7 +211,7 @@ export class PermissionRepository implements IPermissionRepository {
207211
}
208212

209213
const permissionPairs = permissions.map((p) => {
210-
const [resource, action] = p.split(".");
214+
const { resource, action } = parsePermissionString(p);
211215
return { resource, action };
212216
});
213217

packages/features/pbac/infrastructure/repositories/RoleRepository.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import db from "@calcom/prisma";
55

66
import type { Role, RolePermission, PermissionChange, CreateRoleData } from "../../domain/models/Role";
77
import { RoleType } from "../../domain/models/Role";
8+
import { parsePermissionString } from "../../domain/types/permission-registry";
89
import { RoleOutputMapper } from "../mappers/RoleOutputMapper";
910

1011
export class RoleRepository {
@@ -54,7 +55,7 @@ export class RoleRepository {
5455

5556
if (data.permissions.length > 0) {
5657
const permissionData = data.permissions.map((permission) => {
57-
const [resource, action] = permission.split(".");
58+
const { resource, action } = parsePermissionString(permission);
5859
return {
5960
id: uuidv4(),
6061
roleId: role.id,

packages/features/pbac/services/__tests__/permission-diff.service.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { RolePermission } from "@prisma/client";
22
import { describe, it, expect } from "vitest";
33

4+
import type { PermissionString } from "../../domain/types/permission-registry";
45
import { PermissionDiffService } from "../permission-diff.service";
56

67
describe("PermissionDiffService", () => {
@@ -13,13 +14,13 @@ describe("PermissionDiffService", () => {
1314
{ id: "2", roleId: "role1", resource: "booking", action: "read" },
1415
] as RolePermission[];
1516

16-
const newPermissions = ["booking.read", "booking.update", "event.create"];
17+
const newPermissions = ["booking.read", "booking.update", "eventType.create"] as PermissionString[];
1718

1819
const result = service.calculateDiff(newPermissions, existingPermissions);
1920

2021
expect(result.toAdd).toEqual([
2122
{ resource: "booking", action: "update" },
22-
{ resource: "event", action: "create" },
23+
{ resource: "eventType", action: "create" },
2324
]);
2425

2526
expect(result.toRemove).toEqual([{ id: "1", roleId: "role1", resource: "booking", action: "create" }]);
@@ -37,7 +38,7 @@ describe("PermissionDiffService", () => {
3738
});
3839

3940
it("should handle empty existing permissions", () => {
40-
const newPermissions = ["booking.create", "booking.read"];
41+
const newPermissions = ["booking.create", "booking.read"] as PermissionString[];
4142

4243
const result = service.calculateDiff(newPermissions, []);
4344

@@ -53,11 +54,15 @@ describe("PermissionDiffService", () => {
5354
{ id: "1", roleId: "role1", resource: "booking", action: "create" },
5455
] as RolePermission[];
5556

56-
const newPermissions = ["booking.create", "booking._resource", "event.create"];
57+
const newPermissions = [
58+
"booking.create",
59+
"booking._resource",
60+
"eventType.create",
61+
] as PermissionString[];
5762

5863
const result = service.calculateDiff(newPermissions, existingPermissions);
5964

60-
expect(result.toAdd).toEqual([{ resource: "event", action: "create" }]);
65+
expect(result.toAdd).toEqual([{ resource: "eventType", action: "create" }]);
6166
expect(result.toRemove).toEqual([]);
6267
});
6368

@@ -67,7 +72,7 @@ describe("PermissionDiffService", () => {
6772
{ id: "2", roleId: "role1", resource: "booking", action: "read" },
6873
] as RolePermission[];
6974

70-
const newPermissions = ["booking.create", "booking.read"];
75+
const newPermissions = ["booking.create", "booking.read"] as PermissionString[];
7176

7277
const result = service.calculateDiff(newPermissions, existingPermissions);
7378

packages/features/pbac/services/permission-diff.service.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import type { RolePermission } from "../domain/models/Role";
2-
3-
export type PermissionString = string;
4-
export type ParsedPermission = { resource: string; action: string };
2+
import {
3+
parsePermissionString,
4+
isValidPermissionString,
5+
type PermissionString,
6+
type ParsedPermission,
7+
} from "../domain/types/permission-registry";
58

69
export class PermissionDiffService {
7-
private parsePermission(permission: PermissionString): ParsedPermission {
8-
const [resource, action] = permission.split(".");
9-
return { resource, action };
10-
}
11-
1210
private filterInternalPermissions(permissions: PermissionString[]): PermissionString[] {
1311
return permissions.filter((permission) => {
14-
const { action } = this.parsePermission(permission);
12+
// Skip invalid permissions entirely
13+
if (!isValidPermissionString(permission)) {
14+
return false;
15+
}
16+
const { action } = parsePermissionString(permission);
1517
return action !== "_resource";
1618
});
1719
}
@@ -28,14 +30,14 @@ export class PermissionDiffService {
2830

2931
const newSet = new Set(
3032
filteredPermissions.map((p) => {
31-
const parsed = this.parsePermission(p);
33+
const parsed = parsePermissionString(p);
3234
return this.createPermissionKey(parsed);
3335
})
3436
);
3537

3638
// Calculate permissions to add and remove
3739
const toAdd = filteredPermissions
38-
.map((p) => this.parsePermission(p))
40+
.map((p) => parsePermissionString(p))
3941
.filter((p) => !existingSet.has(this.createPermissionKey(p)));
4042

4143
const toRemove = existingPermissions.filter((p) => !newSet.has(this.createPermissionKey(p)));

packages/features/pbac/services/permission.service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,16 @@ export class PermissionService {
1818
validatePermission(permission: PermissionString): PermissionValidationResult {
1919
try {
2020
const permissionObj = PermissionMapper.fromPermissionString(permission);
21-
const isValid = !!PERMISSION_REGISTRY[permissionObj.resource]?.[permissionObj.action];
21+
const registryEntry = PERMISSION_REGISTRY[permissionObj.resource];
22+
const actionEntry = registryEntry?.[permissionObj.action];
23+
const isValid = !!actionEntry;
24+
2225
return {
2326
isValid,
2427
error: isValid ? null : `Invalid permission: ${permission}`,
2528
};
2629
} catch (error) {
30+
console.debug(`[DEBUG] Error validating permission ${permission}:`, error);
2731
if (error instanceof Error) {
2832
return {
2933
isValid: false,

packages/trpc/server/routers/viewer/pbac/_router.tsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from "zod";
22

33
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
4-
import { Resource, CrudAction, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
4+
import { isValidPermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
55
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
66
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
77
import { RoleService } from "@calcom/features/pbac/services/role.service";
@@ -13,21 +13,7 @@ import { router } from "../../../trpc";
1313

1414
// Create a Zod schema for PermissionString that validates the format
1515
const permissionStringSchema = z.custom<PermissionString>((val) => {
16-
if (typeof val !== "string") return false;
17-
18-
const [resource, action] = val.split(".");
19-
20-
const isValidResource = Object.values(Resource).includes(resource as Resource);
21-
22-
if (action === "_resource") {
23-
return true;
24-
}
25-
26-
const isValidAction =
27-
Object.values(CrudAction).includes(action as CrudAction) ||
28-
Object.values(CustomAction).includes(action as CustomAction);
29-
30-
return isValidResource && isValidAction;
16+
return isValidPermissionString(val);
3117
}, "Invalid permission string format. Must be 'resource.action' where resource and action are valid enums");
3218

3319
// Schema for creating/updating roles

0 commit comments

Comments
 (0)