fix: (PBAC) Add organization fallback on resources (#22733)

* add fallback to service

* fix: org memebership fallback

* remove log

* remove log

* add merge test case

* add test for no parentId on team for getResources
This commit is contained in:
sean-brydon
2025-07-26 01:07:05 +01:00
committed by GitHub
parent e42013bee1
commit b155d736d8
4 changed files with 312 additions and 7 deletions
@@ -9,7 +9,9 @@ export interface IPermissionRepository {
teamId: number;
userId: number;
customRoleId: string | null;
team_parentId?: number;
team: {
parentId: number | null;
};
} | null>;
getMembershipByUserAndTeam(
@@ -20,7 +22,9 @@ export interface IPermissionRepository {
teamId: number;
userId: number;
customRoleId: string | null;
team_parentId?: number;
team: {
parentId: number | null;
};
} | null>;
getOrgMembership(
@@ -45,6 +49,11 @@ export interface IPermissionRepository {
resource: Resource
): Promise<(CrudAction | CustomAction)[]>;
/**
* Gets all permissions for a specific resource by role ID
*/
getResourcePermissionsByRoleId(roleId: string, resource: Resource): Promise<(CrudAction | CustomAction)[]>;
/**
* Gets all team IDs where the user has a specific permission
*/
@@ -179,6 +179,23 @@ export class PermissionRepository implements IPermissionRepository {
return teamPermissions.map((p) => p.action as CrudAction | CustomAction);
}
async getResourcePermissionsByRoleId(
roleId: string,
resource: Resource
): Promise<(CrudAction | CustomAction)[]> {
const permissions = await this.client.rolePermission.findMany({
where: {
roleId,
OR: [{ resource }, { resource: Resource.All }],
},
select: {
action: true,
resource: true,
},
});
return permissions.map((p) => p.action as CrudAction | CustomAction);
}
async getTeamIdsWithPermission(userId: number, permission: PermissionString): Promise<number[]> {
return this.getTeamIdsWithPermissions(userId, [permission]);
}
@@ -6,6 +6,7 @@ import type { MembershipRole } from "@calcom/prisma/enums";
import type { IPermissionRepository } from "../../domain/repositories/IPermissionRepository";
import type { PermissionString } from "../../domain/types/permission-registry";
import { Resource } from "../../domain/types/permission-registry";
import { PermissionCheckService } from "../permission-check.service";
import type { PermissionService } from "../permission.service";
@@ -34,6 +35,7 @@ describe("PermissionCheckService", () => {
checkRolePermission: vi.fn(),
checkRolePermissions: vi.fn(),
getResourcePermissions: vi.fn(),
getResourcePermissionsByRoleId: vi.fn(),
getTeamIdsWithPermission: vi.fn(),
getTeamIdsWithPermissions: vi.fn(),
} as MockRepository;
@@ -78,6 +80,7 @@ describe("PermissionCheckService", () => {
teamId: membership.teamId,
userId: membership.userId,
customRoleId: membership.customRoleId,
team: { parentId: null },
});
mockRepository.getOrgMembership.mockResolvedValueOnce(null);
mockRepository.checkRolePermission.mockResolvedValueOnce(true);
@@ -192,6 +195,7 @@ describe("PermissionCheckService", () => {
teamId: membership.teamId,
userId: membership.userId,
customRoleId: membership.customRoleId,
team: { parentId: null },
});
mockRepository.getOrgMembership.mockResolvedValueOnce(null);
mockRepository.checkRolePermissions.mockResolvedValueOnce(true);
@@ -268,6 +272,7 @@ describe("PermissionCheckService", () => {
teamId: membership.teamId,
userId: membership.userId,
customRoleId: membership.customRoleId,
team: { parentId: null },
});
mockRepository.getOrgMembership.mockResolvedValueOnce(null);
mockRepository.checkRolePermissions.mockResolvedValueOnce(false);
@@ -379,4 +384,253 @@ describe("PermissionCheckService", () => {
expect(mockRepository.getTeamIdsWithPermissions).toHaveBeenCalledWith(1, permissions);
});
});
describe("getResourcePermissions", () => {
it("should return empty array when PBAC is disabled", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(false);
const result = await service.getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.EventType,
});
expect(result).toEqual([]);
expect(mockFeaturesRepository.checkIfTeamHasFeature).toHaveBeenCalledWith(1, "pbac");
expect(mockRepository.getMembershipByUserAndTeam).not.toHaveBeenCalled();
});
it("should return team-level permissions when PBAC is enabled and no org membership", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({
id: 1,
teamId: 1,
userId: 1,
customRoleId: "team_role",
team: { parentId: null },
});
mockRepository.getOrgMembership.mockResolvedValueOnce(null);
mockRepository.getResourcePermissionsByRoleId.mockResolvedValueOnce(["create", "read"]);
const result = await service.getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.EventType,
});
expect(result).toEqual(["eventType.create", "eventType.read"]);
expect(mockRepository.getMembershipByUserAndTeam).toHaveBeenCalledWith(1, 1);
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenCalledWith(
"team_role",
Resource.EventType
);
expect(mockRepository.getOrgMembership).not.toHaveBeenCalled();
});
it("should return only team permissions when team has no parentId", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({
id: 1,
teamId: 1,
userId: 1,
customRoleId: "team_role",
team: { parentId: null },
});
mockRepository.getResourcePermissionsByRoleId.mockResolvedValueOnce(["create", "read", "update"]);
const result = await service.getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.EventType,
});
expect(result).toEqual(["eventType.create", "eventType.read", "eventType.update"]);
expect(mockRepository.getMembershipByUserAndTeam).toHaveBeenCalledWith(1, 1);
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenCalledTimes(1);
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenCalledWith(
"team_role",
Resource.EventType
);
expect(mockRepository.getOrgMembership).not.toHaveBeenCalled();
});
it("should return combined team and org permissions when both exist", 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: "org_role",
});
// Mock team permissions - create implies read
mockRepository.getResourcePermissionsByRoleId
.mockResolvedValueOnce(["create", "read"]) // team permissions
.mockResolvedValueOnce(["update", "delete", "read"]); // org permissions - update/delete imply read
const result = await service.getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.EventType,
});
expect(result).toEqual(["eventType.create", "eventType.read", "eventType.update", "eventType.delete"]);
expect(mockRepository.getMembershipByUserAndTeam).toHaveBeenCalledWith(1, 1);
expect(mockRepository.getOrgMembership).toHaveBeenCalledWith(1, 2);
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenCalledTimes(2);
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenNthCalledWith(
1,
"team_role",
Resource.EventType
);
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenNthCalledWith(
2,
"org_role",
Resource.EventType
);
});
it("should deduplicate permissions when team and org have overlapping permissions", 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: "org_role",
});
// Mock overlapping permissions - both include read, update implies read
mockRepository.getResourcePermissionsByRoleId
.mockResolvedValueOnce(["create", "read"]) // team permissions - create implies read
.mockResolvedValueOnce(["read", "update"]); // org permissions - update implies read, explicit read
const result = await service.getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.EventType,
});
expect(result).toEqual(["eventType.create", "eventType.read", "eventType.update"]);
expect(result).toHaveLength(3); // Should not have duplicate "read"
});
it("should return only org permissions when team has no custom role", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({
id: 1,
teamId: 1,
userId: 1,
customRoleId: null,
team: { parentId: 2 },
});
mockRepository.getOrgMembership.mockResolvedValueOnce({
id: 2,
teamId: 2,
userId: 1,
customRoleId: "org_role",
});
// Update and delete imply read access
mockRepository.getResourcePermissionsByRoleId.mockResolvedValueOnce(["update", "delete", "read"]);
const result = await service.getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.EventType,
});
expect(result).toEqual(["eventType.update", "eventType.delete", "eventType.read"]);
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenCalledTimes(1);
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenCalledWith(
"org_role",
Resource.EventType
);
});
it("should return empty array when no membership found", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce(null);
const result = await service.getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.EventType,
});
expect(result).toEqual([]);
expect(mockRepository.getResourcePermissionsByRoleId).not.toHaveBeenCalled();
});
it("should return empty array and log error when repository throws", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
mockRepository.getMembershipByUserAndTeam.mockRejectedValueOnce(new Error("Database error"));
const result = await service.getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.EventType,
});
expect(result).toEqual([]);
});
it("should enforce correct hierarchy - org permissions should take precedence over team permissions", async () => {
mockFeaturesRepository.checkIfTeamHasFeature.mockResolvedValueOnce(true);
mockRepository.getMembershipByUserAndTeam.mockResolvedValueOnce({
id: 1,
teamId: 1,
userId: 1,
customRoleId: "admin_team_role",
team: { parentId: 2 },
});
mockRepository.getOrgMembership.mockResolvedValueOnce({
id: 2,
teamId: 2,
userId: 1,
customRoleId: "restricted_org_role",
});
// Team role has broad permissions - CUD actions include read
// Org role has restricted permissions (only read)
mockRepository.getResourcePermissionsByRoleId
.mockResolvedValueOnce(["create", "read", "update"]) // team permissions - CRU
.mockResolvedValueOnce(["delete"]); // org permissions - delete only
const result = await service.getResourcePermissions({
userId: 1,
teamId: 1,
resource: Resource.EventType,
});
// User gets eventType.delete because they have this in the org
expect(result).toEqual(["eventType.create", "eventType.read", "eventType.update", "eventType.delete"]);
// Verify both team and org permissions were fetched
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenCalledTimes(2);
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenNthCalledWith(
1,
"admin_team_role",
Resource.EventType
);
expect(mockRepository.getResourcePermissionsByRoleId).toHaveBeenNthCalledWith(
2,
"restricted_org_role",
Resource.EventType
);
});
});
});
@@ -6,7 +6,12 @@ import type { MembershipRole } from "@calcom/prisma/enums";
import { PermissionMapper } from "../domain/mappers/PermissionMapper";
import type { PermissionCheck, TeamPermissions } from "../domain/models/Permission";
import type { IPermissionRepository } from "../domain/repositories/IPermissionRepository";
import type { PermissionString, Resource } from "../domain/types/permission-registry";
import type {
PermissionString,
Resource,
CrudAction,
CustomAction,
} from "../domain/types/permission-registry";
import { PermissionRepository } from "../infrastructure/repositories/PermissionRepository";
import { PermissionService } from "./permission.service";
@@ -53,8 +58,28 @@ export class PermissionCheckService {
return [];
}
const actions = await this.repository.getResourcePermissions(userId, teamId, resource);
return actions.map((action) => PermissionMapper.toPermissionString({ resource, action }));
const { membership, orgMembership } = await this.getMembership({ userId, teamId });
const actions = new Set<CrudAction | CustomAction>();
// Get team-level permissions
if (membership?.customRoleId) {
const teamActions = await this.repository.getResourcePermissionsByRoleId(
membership.customRoleId,
resource
);
teamActions.forEach((action) => actions.add(action));
}
// Get org-level permissions as fallback
if (membership?.team?.parentId && orgMembership?.customRoleId) {
const orgActions = await this.repository.getResourcePermissionsByRoleId(
orgMembership.customRoleId,
resource
);
orgActions.forEach((action) => actions.add(action));
}
return Array.from(actions).map((action) => PermissionMapper.toPermissionString({ resource, action }));
} catch (error) {
this.logger.error(error);
return [];
@@ -215,8 +240,8 @@ export class PermissionCheckService {
membership = await this.repository.getMembershipByUserAndTeam(query.userId, query.teamId);
}
if (membership?.team_parentId) {
orgMembership = await this.repository.getOrgMembership(membership.userId, membership.team_parentId);
if (membership?.team.parentId) {
orgMembership = await this.repository.getOrgMembership(membership.userId, membership.team.parentId);
}
return { membership, orgMembership };