Skip to content

Commit 730aab3

Browse files
fix: always check team.parentID even if no membership (calcom#23976)
* fix: always check team.parentID even if no membership * refactor permission handling in create event type handler * remove hasMembership --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
1 parent 63740c0 commit 730aab3

5 files changed

Lines changed: 163 additions & 154 deletions

File tree

packages/features/pbac/domain/repositories/IPermissionRepository.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ export interface IPermissionRepository {
3737
customRoleId: string | null;
3838
} | null>;
3939

40+
getTeamById(teamId: number): Promise<{
41+
id: number;
42+
parentId: number | null;
43+
} | null>;
44+
4045
checkRolePermission(roleId: string, permission: PermissionString): Promise<boolean>;
4146
checkRolePermissions(roleId: string, permissions: PermissionString[]): Promise<boolean>;
4247

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ export class PermissionRepository implements IPermissionRepository {
9494
});
9595
}
9696

97+
async getTeamById(teamId: number) {
98+
return this.client.team.findUnique({
99+
where: { id: teamId },
100+
select: {
101+
id: true,
102+
parentId: true,
103+
},
104+
});
105+
}
106+
97107
async checkRolePermission(roleId: string, permission: PermissionString): Promise<boolean> {
98108
const { resource, action } = parsePermissionString(permission);
99109
const hasPermission = await this.client.rolePermission.findFirst({

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

Lines changed: 104 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe("PermissionCheckService", () => {
3737
getMembershipByMembershipId: vi.fn(),
3838
getMembershipByUserAndTeam: vi.fn(),
3939
getOrgMembership: vi.fn(),
40+
getTeamById: vi.fn(),
4041
getUserMemberships: vi.fn(),
4142
checkRolePermission: vi.fn(),
4243
checkRolePermissions: vi.fn(),
@@ -67,28 +68,14 @@ describe("PermissionCheckService", () => {
6768

6869
describe("checkPermission", () => {
6970
it("should check permission with PBAC enabled", async () => {
70-
const membership = {
71+
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
72+
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({
7173
id: 1,
7274
teamId: 1,
7375
userId: 1,
74-
accepted: true,
75-
role: "ADMIN" as MembershipRole,
7676
customRoleId: "admin_role",
77-
disableImpersonation: false,
78-
createdAt: new Date(),
79-
updatedAt: new Date(),
80-
};
81-
82-
(MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership);
83-
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
84-
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({
85-
id: membership.id,
86-
teamId: membership.teamId,
87-
userId: membership.userId,
88-
customRoleId: membership.customRoleId,
8977
team: { parentId: null },
9078
});
91-
mockRepository.getOrgMembership.mockResolvedValueOnce(null);
9279
mockRepository.checkRolePermission.mockResolvedValueOnce(true);
9380

9481
const result = await service.checkPermission({
@@ -99,12 +86,8 @@ describe("PermissionCheckService", () => {
9986
});
10087

10188
expect(result).toBe(true);
102-
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenCalledWith({
103-
userId: 1,
104-
teamId: 1,
105-
});
10689
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(1, "pbac");
107-
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
90+
expect(mockRepository.getMembershipByUserAndTeam).toHaveBeenCalledWith(1, 1);
10891
expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("admin_role", "eventType.create");
10992
});
11093

@@ -140,7 +123,8 @@ describe("PermissionCheckService", () => {
140123
expect(mockRepository.checkRolePermission).not.toHaveBeenCalled();
141124
});
142125

143-
it("should return false if membership not found", async () => {
126+
it("should return false if membership not found when PBAC disabled", async () => {
127+
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(false);
144128
(MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(null);
145129

146130
const result = await service.checkPermission({
@@ -153,21 +137,47 @@ describe("PermissionCheckService", () => {
153137
expect(result).toBe(false);
154138
});
155139

156-
it("should return false if PBAC enabled but no customRoleId", async () => {
157-
const membership = {
140+
it("should check org-level permissions when user has no team membership but PBAC is enabled", async () => {
141+
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
142+
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce(null);
143+
mockRepository.getTeamById.mockResolvedValueOnce({ id: 1, parentId: 2 });
144+
mockRepository.getOrgMembership.mockResolvedValueOnce({
145+
id: 100,
146+
teamId: 2,
147+
userId: 1,
148+
customRoleId: "org_admin_role",
149+
});
150+
mockRepository.checkRolePermission.mockResolvedValueOnce(true);
151+
152+
const result = await service.checkPermission({
153+
userId: 1,
154+
teamId: 1,
155+
permission: "eventType.create",
156+
fallbackRoles: ["ADMIN", "OWNER"],
157+
});
158+
159+
expect(result).toBe(true);
160+
expect(mockRepository.getMembershipByUserAndTeam).toHaveBeenCalledWith(1, 1);
161+
expect(mockRepository.getTeamById).toHaveBeenCalledWith(1);
162+
expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 2);
163+
expect(mockRepository.checkRolePermission).toHaveBeenCalledWith("org_admin_role", "eventType.create");
164+
});
165+
166+
it("should return false if PBAC enabled but no customRoleId on team or org", async () => {
167+
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
168+
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({
158169
id: 1,
159170
teamId: 1,
160171
userId: 1,
161-
accepted: true,
162-
role: "ADMIN" as MembershipRole,
163172
customRoleId: null,
164-
disableImpersonation: false,
165-
createdAt: new Date(),
166-
updatedAt: new Date(),
167-
};
168-
169-
(MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership);
170-
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
173+
team: { parentId: 2 },
174+
});
175+
mockRepository.getOrgMembership.mockResolvedValueOnce({
176+
id: 100,
177+
teamId: 2,
178+
userId: 1,
179+
customRoleId: null,
180+
});
171181

172182
const result = await service.checkPermission({
173183
userId: 1,
@@ -182,28 +192,14 @@ describe("PermissionCheckService", () => {
182192

183193
describe("checkPermissions", () => {
184194
it("should check multiple permissions with PBAC enabled", async () => {
185-
const membership = {
195+
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
196+
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({
186197
id: 1,
187198
teamId: 1,
188199
userId: 1,
189-
accepted: true,
190-
role: "ADMIN" as MembershipRole,
191200
customRoleId: "admin_role",
192-
disableImpersonation: false,
193-
createdAt: new Date(),
194-
updatedAt: new Date(),
195-
};
196-
197-
(MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership);
198-
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
199-
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({
200-
id: membership.id,
201-
teamId: membership.teamId,
202-
userId: membership.userId,
203-
customRoleId: membership.customRoleId,
204201
team: { parentId: null },
205202
});
206-
mockRepository.getOrgMembership.mockResolvedValueOnce(null);
207203
mockRepository.checkRolePermissions.mockResolvedValueOnce(true);
208204

209205
const result = await service.checkPermissions({
@@ -214,12 +210,8 @@ describe("PermissionCheckService", () => {
214210
});
215211

216212
expect(result).toBe(true);
217-
expect(MembershipRepository.findUniqueByUserIdAndTeamId).toHaveBeenCalledWith({
218-
userId: 1,
219-
teamId: 1,
220-
});
221213
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(1, "pbac");
222-
expect(mockRepository.getMembershipByMembershipId).toHaveBeenCalledWith(1);
214+
expect(mockRepository.getMembershipByUserAndTeam).toHaveBeenCalledWith(1, 1);
223215
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("admin_role", [
224216
"eventType.create",
225217
"team.invite",
@@ -259,25 +251,12 @@ describe("PermissionCheckService", () => {
259251
});
260252

261253
it("should return false when permissions array is empty", async () => {
262-
const membership = {
254+
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
255+
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({
263256
id: 1,
264257
teamId: 1,
265258
userId: 1,
266-
accepted: true,
267-
role: "MEMBER" as MembershipRole, // Change to MEMBER so fallback also fails
268259
customRoleId: "admin_role",
269-
disableImpersonation: false,
270-
createdAt: new Date(),
271-
updatedAt: new Date(),
272-
};
273-
274-
(MembershipRepository.findUniqueByUserIdAndTeamId as Mock).mockResolvedValueOnce(membership);
275-
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
276-
mockRepository.getMembershipByMembershipId.mockResolvedValueOnce({
277-
id: membership.id,
278-
teamId: membership.teamId,
279-
userId: membership.userId,
280-
customRoleId: membership.customRoleId,
281260
team: { parentId: null },
282261
});
283262
mockRepository.getOrgMembership.mockResolvedValueOnce(null);
@@ -293,6 +272,35 @@ describe("PermissionCheckService", () => {
293272
expect(result).toBe(false);
294273
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("admin_role", []);
295274
});
275+
276+
it("should check org-level permissions when user has no team membership with checkPermissions", async () => {
277+
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
278+
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce(null);
279+
mockRepository.getTeamById.mockResolvedValueOnce({ id: 1, parentId: 2 });
280+
mockRepository.getOrgMembership.mockResolvedValueOnce({
281+
id: 100,
282+
teamId: 2,
283+
userId: 1,
284+
customRoleId: "org_admin_role",
285+
});
286+
mockRepository.checkRolePermissions.mockResolvedValueOnce(true);
287+
288+
const result = await service.checkPermissions({
289+
userId: 1,
290+
teamId: 1,
291+
permissions: ["eventType.create", "team.invite"],
292+
fallbackRoles: ["ADMIN", "OWNER"],
293+
});
294+
295+
expect(result).toBe(true);
296+
expect(mockRepository.getMembershipByUserAndTeam).toHaveBeenCalledWith(1, 1);
297+
expect(mockRepository.getTeamById).toHaveBeenCalledWith(1);
298+
expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 2);
299+
expect(mockRepository.checkRolePermissions).toHaveBeenCalledWith("org_admin_role", [
300+
"eventType.create",
301+
"team.invite",
302+
]);
303+
});
296304
});
297305

298306
describe("getUserPermissions", () => {
@@ -392,6 +400,33 @@ describe("PermissionCheckService", () => {
392400
});
393401

394402
describe("getResourcePermissions", () => {
403+
it("should return org permissions when user has no team membership but has org membership", async () => {
404+
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
405+
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce(null);
406+
mockRepository.getTeamById.mockResolvedValueOnce({ id: 1, parentId: 2 });
407+
mockRepository.getOrgMembership.mockResolvedValueOnce({
408+
id: 100,
409+
teamId: 2,
410+
userId: 1,
411+
customRoleId: "org_role",
412+
});
413+
mockRepository.getResourcePermissionsByRoleId.mockResolvedValueOnce(["create", "read", "update"]);
414+
415+
const result = await service.getResourcePermissions({
416+
userId: 1,
417+
teamId: 1,
418+
resource: Resource.EventType,
419+
});
420+
421+
expect(result).toEqual(["eventType.create", "eventType.read", "eventType.update"]);
422+
expect(mockRepository.getMembershipByUserAndTeam).toHaveBeenCalledWith(1, 1);
423+
expect(mockRepository.getTeamById).toHaveBeenCalledWith(1);
424+
expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 2);
425+
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenCalledWith(
426+
"org_role",
427+
Resource.EventType
428+
);
429+
});
395430
it("should return empty array when PBAC is disabled", async () => {
396431
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(false);
397432

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

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ export class PermissionCheckService {
7171
teamActions.forEach((action) => actions.add(action));
7272
}
7373

74-
// Get org-level permissions as fallback
75-
if (membership?.team?.parentId && orgMembership?.customRoleId) {
74+
// Get org-level permissions (works even without team membership)
75+
if (orgMembership?.customRoleId) {
7676
const orgActions = await this.repository.getResourcePermissionsByRoleId(
7777
orgMembership.customRoleId,
7878
resource
@@ -108,27 +108,23 @@ export class PermissionCheckService {
108108
return false;
109109
}
110110

111-
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({
112-
userId,
113-
teamId,
114-
});
115-
116-
if (!membership) return false;
117-
118111
const isPBACEnabled = await this.featuresRepository.checkIfTeamHasFeature(
119112
teamId,
120113
this.PBAC_FEATURE_FLAG
121114
);
122115

123116
if (isPBACEnabled) {
124-
if (!membership.customRoleId) {
125-
this.logger.info(`PBAC is enabled for ${teamId} but no custom role is set on membership relation`);
126-
return false;
127-
}
128-
129-
return this.hasPermission({ membershipId: membership.id }, permission);
117+
// Check if user has permission through team or org membership
118+
return this.hasPermission({ userId, teamId }, permission);
130119
}
131120

121+
// Fallback to role-based check only if user has team membership
122+
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({
123+
userId,
124+
teamId,
125+
});
126+
127+
if (!membership) return false;
132128
return this.checkFallbackRoles(membership.role, fallbackRoles);
133129
} catch (error) {
134130
this.logger.error(error);
@@ -157,27 +153,23 @@ export class PermissionCheckService {
157153
return false;
158154
}
159155

160-
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({
161-
userId,
162-
teamId,
163-
});
164-
165-
if (!membership) return false;
166-
167156
const isPBACEnabled = await this.featuresRepository.checkIfTeamHasFeature(
168157
teamId,
169158
this.PBAC_FEATURE_FLAG
170159
);
171160

172161
if (isPBACEnabled) {
173-
if (!membership.customRoleId) {
174-
this.logger.info(`PBAC is enabled for ${teamId} but no custom role is set on membership relation`);
175-
return false;
176-
}
177-
178-
return this.hasPermissions({ membershipId: membership.id }, permissions);
162+
// Check if user has permissions through team or org membership
163+
return this.hasPermissions({ userId, teamId }, permissions);
179164
}
180165

166+
// Fallback to role-based check only if user has team membership
167+
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({
168+
userId,
169+
teamId,
170+
});
171+
172+
if (!membership) return false;
181173
return this.checkFallbackRoles(membership.role, fallbackRoles);
182174
} catch (error) {
183175
this.logger.error(error);
@@ -241,8 +233,16 @@ export class PermissionCheckService {
241233
membership = await this.repository.getMembershipByUserAndTeam(query.userId, query.teamId);
242234
}
243235

236+
// Get org membership either through the team membership or directly from teamId
244237
if (membership?.team.parentId) {
238+
// User has team membership, check org through that
245239
orgMembership = await this.repository.getOrgMembership(membership.userId, membership.team.parentId);
240+
} else if (query.userId && query.teamId) {
241+
// No team membership, but check if team belongs to an org
242+
const team = await this.repository.getTeamById(query.teamId);
243+
if (team?.parentId) {
244+
orgMembership = await this.repository.getOrgMembership(query.userId, team.parentId);
245+
}
246246
}
247247

248248
return { membership, orgMembership };

0 commit comments

Comments
 (0)