fix: Move pbac to prisma from kysley (#22318)

* fixes

* fix prisma mock to include transaction callbacj

* remove redudant transactions

* remove hard requirement on transactions in services

* remove mocktrx

* fix cubic feedback

* correct permission updating logic of roles. Implement diff service + domain types

* remove role mapper
This commit is contained in:
sean-brydon
2025-07-10 10:24:50 +00:00
committed by GitHub
parent f69c4a900e
commit 33b5bf1db8
10 changed files with 452 additions and 422 deletions
@@ -1,60 +0,0 @@
import type { RoleType } from "@calcom/kysely/types";
import type { Role, RolePermission, RoleType as DomainRoleType } from "../models/Role";
type KyselyRole = {
id: string;
name: string;
color: string | null;
description: string | null;
teamId: number | null;
type: RoleType;
createdAt: Date;
updatedAt: Date;
permissions: Array<{
id: string;
resource: string;
action: string;
}>;
};
export class RoleMapper {
static toDomain(kyselyRole: KyselyRole): Role {
return {
id: kyselyRole.id,
name: kyselyRole.name,
description: kyselyRole.description || undefined,
color: kyselyRole.color || undefined,
teamId: kyselyRole.teamId || undefined,
type: kyselyRole.type as DomainRoleType,
permissions: kyselyRole.permissions.map((p) => ({
id: p.id,
resource: p.resource,
action: p.action,
})),
createdAt: kyselyRole.createdAt,
updatedAt: kyselyRole.updatedAt,
};
}
static toKysely(role: Role): Omit<KyselyRole, "permissions"> {
return {
id: role.id,
name: role.name,
description: role.description || null,
color: role.color || null,
teamId: role.teamId || null,
type: role.type as DomainRoleType,
createdAt: role.createdAt,
updatedAt: role.updatedAt,
};
}
static permissionToKysely(roleId: string, permission: RolePermission) {
return {
roleId,
resource: permission.resource,
action: permission.action,
};
}
}
@@ -9,8 +9,10 @@ export type RoleType = (typeof RoleType)[keyof typeof RoleType];
export interface RolePermission {
id: string;
roleId: string;
resource: string;
action: string;
createdAt: Date;
}
export interface Role {
@@ -43,3 +45,8 @@ export interface UpdateRolePermissionsData {
name?: string;
};
}
export type PermissionChange = {
resource: string;
action: string;
};
@@ -1,9 +1,4 @@
import type { Transaction } from "kysely";
import type { DB } from "@calcom/kysely";
import type { Role, CreateRoleData } from "../models/Role";
import type { PermissionString } from "../types/permission-registry";
import type { Role, CreateRoleData, RolePermission, PermissionChange } from "../models/Role";
export interface IRoleRepository {
findByName(name: string, teamId?: number): Promise<Role | null>;
@@ -14,12 +9,15 @@ export interface IRoleRepository {
delete(id: string): Promise<void>;
update(
roleId: string,
permissions: PermissionString[],
permissionChanges: {
toAdd: PermissionChange[];
toRemove: PermissionChange[];
},
updates?: {
color?: string;
name?: string;
description?: string;
}
): Promise<Role>;
transaction<T>(callback: (repository: IRoleRepository, trx: Transaction<DB>) => Promise<T>): Promise<T>;
getPermissions(roleId: string): Promise<RolePermission[]>;
}
@@ -0,0 +1,33 @@
import type { Role as PrismaRole, RolePermission as PrismaRolePermission } from "@calcom/prisma/client";
import type { Role, RolePermission } from "../../domain/models/Role";
export class RoleOutputMapper {
static toDomain(prismaRole: PrismaRole & { permissions: PrismaRolePermission[] }): Role {
return {
id: prismaRole.id,
name: prismaRole.name,
color: prismaRole.color || undefined,
description: prismaRole.description || undefined,
teamId: prismaRole.teamId || undefined,
type: prismaRole.type,
permissions: prismaRole.permissions.map(this.toDomainPermission),
createdAt: prismaRole.createdAt,
updatedAt: prismaRole.updatedAt,
};
}
static toDomainPermission(prismaPermission: PrismaRolePermission): RolePermission {
return {
id: prismaPermission.id,
roleId: prismaPermission.roleId,
resource: prismaPermission.resource,
action: prismaPermission.action,
createdAt: prismaPermission.createdAt,
};
}
static toDomainList(prismaRoles: (PrismaRole & { permissions: PrismaRolePermission[] })[]): Role[] {
return prismaRoles.map(this.toDomain);
}
}
@@ -1,4 +1,5 @@
import kysely from "@calcom/kysely";
import db from "@calcom/prisma";
import type { PrismaClient as PrismaClientWithExtensions } from "@calcom/prisma";
import { PermissionMapper } from "../../domain/mappers/PermissionMapper";
import type { TeamPermissions } from "../../domain/models/Permission";
@@ -7,114 +8,101 @@ import type { CrudAction, CustomAction } from "../../domain/types/permission-reg
import { Resource, type PermissionString } from "../../domain/types/permission-registry";
export class PermissionRepository implements IPermissionRepository {
private client: PrismaClientWithExtensions;
constructor(client: PrismaClientWithExtensions = db) {
this.client = client;
}
async getUserMemberships(userId: number): Promise<TeamPermissions[]> {
const memberships = await kysely
.selectFrom("Membership")
.innerJoin("Role", "Role.id", "Membership.customRoleId")
.leftJoin("RolePermission", "RolePermission.roleId", "Role.id")
.select(["Membership.teamId", "Role.id as roleId", "RolePermission.resource", "RolePermission.action"])
.where("Membership.userId", "=", userId)
.execute();
// Group permissions by teamId and roleId
const membershipsWithPermissions = memberships.reduce((acc, membership) => {
const key = `${membership.teamId}-${membership.roleId}`;
if (!acc[key]) {
acc[key] = {
teamId: membership.teamId,
role: {
id: membership.roleId,
permissions: [],
const memberships = await this.client.membership.findMany({
where: { userId },
include: {
customRole: {
include: {
permissions: true,
},
};
}
if (membership.resource && membership.action) {
acc[key].role.permissions.push({
resource: membership.resource,
action: membership.action,
});
}
return acc;
}, {} as Record<string, any>);
return PermissionMapper.toDomain(Object.values(membershipsWithPermissions));
},
},
});
// Map to expected structure for PermissionMapper
const mapped = memberships.map((membership) => ({
teamId: membership.teamId,
role: membership.customRole
? {
id: membership.customRole.id,
permissions: membership.customRole.permissions,
}
: null,
}));
return PermissionMapper.toDomain(mapped);
}
async getMembershipByMembershipId(membershipId: number) {
const result = await kysely
.selectFrom("Membership")
.leftJoin("Team", "Team.id", "Membership.teamId")
.select([
"Membership.id",
"Membership.teamId",
"Membership.userId",
"Membership.customRoleId",
"Team.parentId as team_parentId",
])
.where("Membership.id", "=", membershipId)
.executeTakeFirst();
if (!result) return null;
return {
...result,
team_parentId: result.team_parentId || undefined,
};
return this.client.membership.findUnique({
where: { id: membershipId },
select: {
id: true,
teamId: true,
userId: true,
customRoleId: true,
team: {
select: {
parentId: true,
},
},
},
});
}
async getMembershipByUserAndTeam(userId: number, teamId: number) {
const result = await kysely
.selectFrom("Membership")
.leftJoin("Team", "Team.id", "Membership.teamId")
.select([
"Membership.id",
"Membership.teamId",
"Membership.userId",
"Membership.customRoleId",
"Team.parentId as team_parentId",
])
.where("Membership.userId", "=", userId)
.where("Membership.teamId", "=", teamId)
.executeTakeFirst();
if (!result) return null;
return {
...result,
team_parentId: result.team_parentId || undefined,
};
return this.client.membership.findFirst({
where: {
userId,
teamId,
},
select: {
id: true,
teamId: true,
userId: true,
customRoleId: true,
team: {
select: {
parentId: true,
},
},
},
});
}
async getOrgMembership(userId: number, orgId: number) {
const result = await kysely
.selectFrom("Membership")
.select(["id", "teamId", "userId", "customRoleId"])
.where("userId", "=", userId)
.where("teamId", "=", orgId)
.executeTakeFirst();
return result || null;
return this.client.membership.findFirst({
where: {
userId,
teamId: orgId,
},
select: {
id: true,
teamId: true,
userId: true,
customRoleId: true,
},
});
}
async checkRolePermission(roleId: string, permission: PermissionString): Promise<boolean> {
const [resource, action] = permission.split(".");
const hasPermission = await kysely
.selectFrom("RolePermission")
.select("id")
.where("roleId", "=", roleId)
.where((eb) =>
eb.or([
// Global wildcard
eb.and([eb("resource", "=", "*"), eb("action", "=", "*")]),
// Resource wildcard
eb.and([eb("resource", "=", "*"), eb("action", "=", action)]),
// Action wildcard
eb.and([eb("resource", "=", resource), eb("action", "=", "*")]),
// Exact match
eb.and([eb("resource", "=", resource), eb("action", "=", action)]),
])
)
.executeTakeFirst();
const hasPermission = await this.client.rolePermission.findFirst({
where: {
roleId,
OR: [
{ resource: "*", action: "*" },
{ resource: "*", action },
{ resource, action: "*" },
{ resource, action },
],
},
});
return !!hasPermission;
}
@@ -123,42 +111,38 @@ export class PermissionRepository implements IPermissionRepository {
const [resource, action] = p.split(".");
return { resource, action };
});
const resourceActions = permissionPairs.map((p) => [p.resource, p.action]);
const resources = permissionPairs.map((p) => p.resource);
const actions = permissionPairs.map((p) => p.action);
const matchingPermissionsCount = await kysely
.selectFrom("RolePermission")
.select((eb) => eb.fn.countAll().as("count"))
.where("roleId", "=", roleId)
.where((eb) =>
eb.or([
// Global wildcard
eb.and([eb("resource", "=", "*"), eb("action", "=", "*")]),
// Resource wildcards
eb.and([
eb("resource", "=", "*"),
eb(
"action",
"in",
permissionPairs.map((p) => p.action)
),
]),
// Action wildcards
eb.and([
eb(
"resource",
"in",
permissionPairs.map((p) => p.resource)
),
eb("action", "=", "*"),
]),
// Exact matches
eb.or(
permissionPairs.map((p) => eb.and([eb("resource", "=", p.resource), eb("action", "=", p.action)]))
),
])
const matchingPermissions = await this.client.$queryRaw<[{ count: bigint }]>`
WITH permission_checks AS (
-- Universal permission (*,*)
SELECT 1 as match FROM "RolePermission"
WHERE "roleId" = ${roleId} AND "resource" = '*' AND "action" = '*'
UNION ALL
-- Wildcard resource with specific actions
SELECT 1 as match FROM "RolePermission"
WHERE "roleId" = ${roleId} AND "resource" = '*' AND "action" = ANY(${actions})
UNION ALL
-- Specific resources with wildcard action
SELECT 1 as match FROM "RolePermission"
WHERE "roleId" = ${roleId} AND "action" = '*' AND "resource" = ANY(${resources})
UNION ALL
-- Exact resource-action pairs
SELECT 1 as match FROM "RolePermission"
WHERE "roleId" = ${roleId} AND ("resource", "action") = ANY(${resourceActions})
)
.executeTakeFirstOrThrow();
SELECT COUNT(*) as count FROM permission_checks
`;
return Number(matchingPermissionsCount.count) >= permissions.length;
return Number(matchingPermissions[0].count) >= permissions.length;
}
async getResourcePermissions(
@@ -167,22 +151,26 @@ export class PermissionRepository implements IPermissionRepository {
resource: Resource
): Promise<(CrudAction | CustomAction)[]> {
// Get team-level permissions
const membership = await kysely
.selectFrom("Membership")
.select("customRoleId")
.where("userId", "=", userId)
.where("teamId", "=", teamId)
.executeTakeFirst();
const membership = await this.client.membership.findFirst({
where: {
userId,
teamId,
},
select: {
customRoleId: true,
},
});
if (!membership?.customRoleId) return [];
const teamPermissions = await kysely
.selectFrom("RolePermission")
.select(["action", "resource"])
.where("roleId", "=", membership.customRoleId)
.where((eb) => eb.or([eb("resource", "=", resource), eb("resource", "=", Resource.All)]))
.execute();
const teamPermissions = await this.client.rolePermission.findMany({
where: {
roleId: membership.customRoleId,
OR: [{ resource }, { resource: Resource.All }],
},
select: {
action: true,
resource: true,
},
});
return teamPermissions.map((p) => p.action as CrudAction | CustomAction);
}
}
@@ -1,208 +1,149 @@
import type { Transaction } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import { v4 as uuidv4 } from "uuid";
import type { DB } from "@calcom/kysely";
import kysely from "@calcom/kysely";
import type { PrismaClient as PrismaWithExtensions } from "@calcom/prisma";
import db from "@calcom/prisma";
import { RoleMapper } from "../../domain/mappers/RoleMapper";
import type { Role, RolePermission, PermissionChange, CreateRoleData } from "../../domain/models/Role";
import { RoleType } from "../../domain/models/Role";
import type { CreateRoleData } from "../../domain/models/Role";
import type { IRoleRepository } from "../../domain/repositories/IRoleRepository";
import type { PermissionString } from "../../domain/types/permission-registry";
import { RoleOutputMapper } from "../mappers/RoleOutputMapper";
type KyselyRole = {
id: string;
name: string;
color: string | null;
description: string | null;
teamId: number | null;
type: RoleType;
createdAt: Date;
updatedAt: Date;
permissions: Array<{
id: string;
resource: string;
action: string;
}>;
};
export class RoleRepository {
constructor(private readonly client: PrismaWithExtensions = db) {}
export class RoleRepository implements IRoleRepository {
private getRoleSelect() {
return [
"Role.id",
"Role.name",
"Role.description",
"Role.teamId",
"Role.type",
"Role.color",
"Role.createdAt",
"Role.updatedAt",
(eb: any) =>
jsonArrayFrom(
eb
.selectFrom("RolePermission")
.select(["id", "resource", "action"])
.whereRef("RolePermission.roleId", "=", "Role.id")
).as("permissions"),
] as const;
async findByName(name: string, teamId?: number): Promise<Role | null> {
const role = await this.client.role.findFirst({
where: { name, teamId: teamId ?? null },
include: { permissions: true },
});
return role ? RoleOutputMapper.toDomain(role) : null;
}
async findByName(name: string, teamId?: number) {
const role = await kysely
.selectFrom("Role")
.select(this.getRoleSelect())
.where("name", "=", name)
.where((eb) => (teamId ? eb("teamId", "=", teamId) : eb("teamId", "is", null)))
.executeTakeFirst();
return role ? RoleMapper.toDomain(role as KyselyRole) : null;
async findByTeamId(teamId: number): Promise<Role[]> {
const roles = await this.client.role.findMany({
where: { teamId },
include: { permissions: true },
});
return RoleOutputMapper.toDomainList(roles);
}
async findById(id: string) {
const role = await kysely
.selectFrom("Role")
.select(this.getRoleSelect())
.where("id", "=", id)
.executeTakeFirst();
return role ? RoleMapper.toDomain(role as KyselyRole) : null;
async roleBelongsToTeam(roleId: string, teamId: number): Promise<boolean> {
const role = await this.client.role.findUnique({
where: { id: roleId },
select: { teamId: true },
});
return role?.teamId === teamId;
}
async findByTeamId(teamId: number) {
const roles = await kysely
.selectFrom("Role")
.select(this.getRoleSelect())
.where((eb) => eb.or([eb("Role.teamId", "=", teamId), eb("Role.type", "=", RoleType.SYSTEM)]))
.execute();
return roles.map((role) => RoleMapper.toDomain(role as KyselyRole));
}
async create(data: CreateRoleData) {
const roleId = uuidv4();
await kysely.transaction().execute(async (trx) => {
// Create role
await trx
.insertInto("Role")
.values({
id: roleId,
async create(data: CreateRoleData): Promise<Role> {
const roleId = await this.client.$transaction(async (trx) => {
const role = await trx.role.create({
data: {
id: uuidv4(),
name: data.name,
description: data.description || null,
teamId: data.teamId || null,
type: data.type || RoleType.CUSTOM,
color: data.color || null,
createdAt: new Date(),
updatedAt: new Date(),
})
.execute();
color: data.color ?? null,
description: data.description ?? null,
teamId: data.teamId ?? null,
type: data.type ?? RoleType.CUSTOM,
},
});
// Create permissions only if there are any
if (data.permissions.length > 0) {
const permissionData = data.permissions.map((permission) => {
const [resource, action] = permission.split(".");
return {
id: uuidv4(),
roleId,
roleId: role.id,
resource,
action,
};
});
await trx.insertInto("RolePermission").values(permissionData).execute();
await trx.rolePermission.createMany({ data: permissionData });
}
return roleId;
return role.id;
});
// Fetch complete role with permissions
const completeRole = await this.findById(roleId);
// This should never happen
if (!completeRole) throw new Error("Failed to create role");
return completeRole;
}
async delete(id: string) {
await kysely.transaction().execute(async (trx) => {
await trx.deleteFrom("RolePermission").where("roleId", "=", id).execute();
await trx.deleteFrom("Role").where("id", "=", id).execute();
});
async delete(id: string): Promise<void> {
await this.client.$transaction([
this.client.rolePermission.deleteMany({ where: { roleId: id } }),
this.client.role.delete({ where: { id } }),
]);
}
async update(
roleId: string,
permissions: PermissionString[],
permissionChanges: {
toAdd: PermissionChange[];
toRemove: PermissionChange[];
},
updates?: {
color?: string;
name?: string;
description?: string;
}
) {
await kysely.transaction().execute(async (trx) => {
await this.client.$transaction(async (trx) => {
// Update role metadata if provided
if (updates) {
const updateData: Partial<Pick<KyselyRole, "name" | "color" | "description">> = {};
if (updates.color !== undefined) {
updateData.color = updates.color || null;
}
if (updates.name !== undefined) {
updateData.name = updates.name;
}
if (updates.description !== undefined) {
updateData.description = updates.description || null;
}
await trx.updateTable("Role").set(updateData).where("id", "=", roleId).execute();
const updateData: Record<string, any> = {};
if (updates.color !== undefined) updateData.color = updates.color || null;
if (updates.name !== undefined) updateData.name = updates.name;
if (updates.description !== undefined) updateData.description = updates.description || null;
await trx.role.update({ where: { id: roleId }, data: updateData });
}
// Delete existing permissions
await trx.deleteFrom("RolePermission").where("roleId", "=", roleId).execute();
// Remove permissions that are no longer needed
if (permissionChanges.toRemove.length > 0) {
await trx.rolePermission.deleteMany({
where: {
roleId,
AND: permissionChanges.toRemove.map((p) => ({
resource: p.resource,
action: p.action,
})),
},
});
}
// Create new permissions
const permissionData = permissions.map((permission) => {
const [resource, action] = permission.split(".");
return {
// Add new permissions
if (permissionChanges.toAdd.length > 0) {
const permissionData = permissionChanges.toAdd.map((permission) => ({
id: uuidv4(),
roleId,
resource,
action,
};
});
resource: permission.resource,
action: permission.action,
}));
await trx.insertInto("RolePermission").values(permissionData).execute();
return roleId;
await trx.rolePermission.createMany({ data: permissionData });
}
});
// Fetch updated role
const updatedRole = await kysely
.selectFrom("Role")
.select(this.getRoleSelect())
.where("id", "=", roleId)
.executeTakeFirst();
if (!updatedRole) throw new Error("Failed to update role permissions");
return RoleMapper.toDomain(updatedRole as KyselyRole);
// Fetch complete role with permissions
const completeRole = await this.findById(roleId);
if (!completeRole) throw new Error("Failed to update role");
return completeRole;
}
async transaction<T>(
callback: (repository: IRoleRepository, trx: Transaction<DB>) => Promise<T>
): Promise<T> {
return kysely.transaction().execute(async (trx) => {
// Create a new repository instance with the transaction connection
const transactionRepo = new RoleRepository();
// Pass both the repository and the transaction connection
return callback(transactionRepo, trx);
async findById(roleId: string): Promise<Role | null> {
const role = await this.client.role.findUnique({
where: { id: roleId },
include: {
permissions: true,
},
});
return role ? RoleOutputMapper.toDomain(role) : null;
}
async roleBelongsToTeam(roleId: string, teamId: number) {
const role = await this.findById(roleId);
return role?.teamId === teamId;
async getPermissions(roleId: string): Promise<RolePermission[]> {
const permissions = await this.client.rolePermission.findMany({
where: { roleId },
});
return permissions.map(RoleOutputMapper.toDomainPermission);
}
}
@@ -0,0 +1,78 @@
import type { RolePermission } from "@prisma/client";
import { describe, it, expect } from "vitest";
import { PermissionDiffService } from "../permission-diff.service";
describe("PermissionDiffService", () => {
const service = new PermissionDiffService();
describe("calculateDiff", () => {
it("should calculate permissions to add and remove", () => {
const existingPermissions = [
{ id: "1", roleId: "role1", resource: "booking", action: "create" },
{ id: "2", roleId: "role1", resource: "booking", action: "read" },
] as RolePermission[];
const newPermissions = ["booking.read", "booking.update", "event.create"];
const result = service.calculateDiff(newPermissions, existingPermissions);
expect(result.toAdd).toEqual([
{ resource: "booking", action: "update" },
{ resource: "event", action: "create" },
]);
expect(result.toRemove).toEqual([{ id: "1", roleId: "role1", resource: "booking", action: "create" }]);
});
it("should handle empty new permissions", () => {
const existingPermissions = [
{ id: "1", roleId: "role1", resource: "booking", action: "create" },
] as RolePermission[];
const result = service.calculateDiff([], existingPermissions);
expect(result.toAdd).toEqual([]);
expect(result.toRemove).toEqual(existingPermissions);
});
it("should handle empty existing permissions", () => {
const newPermissions = ["booking.create", "booking.read"];
const result = service.calculateDiff(newPermissions, []);
expect(result.toAdd).toEqual([
{ resource: "booking", action: "create" },
{ resource: "booking", action: "read" },
]);
expect(result.toRemove).toEqual([]);
});
it("should filter out internal _resource permissions", () => {
const existingPermissions = [
{ id: "1", roleId: "role1", resource: "booking", action: "create" },
] as RolePermission[];
const newPermissions = ["booking.create", "booking._resource", "event.create"];
const result = service.calculateDiff(newPermissions, existingPermissions);
expect(result.toAdd).toEqual([{ resource: "event", action: "create" }]);
expect(result.toRemove).toEqual([]);
});
it("should handle identical permissions with no changes needed", () => {
const existingPermissions = [
{ id: "1", roleId: "role1", resource: "booking", action: "create" },
{ id: "2", roleId: "role1", resource: "booking", action: "read" },
] as RolePermission[];
const newPermissions = ["booking.create", "booking.read"];
const result = service.calculateDiff(newPermissions, existingPermissions);
expect(result.toAdd).toEqual([]);
expect(result.toRemove).toEqual([]);
});
});
});
@@ -5,9 +5,19 @@ import { RoleType } from "@calcom/prisma/enums";
import type { Role } from "../../domain/models/Role";
import type { IRoleRepository } from "../../domain/repositories/IRoleRepository";
import type { PermissionString } from "../../domain/types/permission-registry";
import type { PermissionDiffService } from "../permission-diff.service";
import { RoleService } from "../role.service";
vi.mock("../../infrastructure/repositories/RoleRepository");
vi.mock("../permission-diff.service");
// Mock db.$transaction
vi.mock("@calcom/prisma", () => ({
default: {
$transaction: vi.fn((cb) => cb({ membership: { update: vi.fn() } })),
membership: { update: vi.fn() },
},
}));
type MockRepository = {
[K in keyof IRoleRepository]: Mock;
@@ -16,6 +26,7 @@ type MockRepository = {
describe("RoleService", () => {
let service: RoleService;
let mockRepository: MockRepository;
let mockPermissionDiffService: { calculateDiff: Mock };
beforeEach(() => {
vi.clearAllMocks();
@@ -26,10 +37,19 @@ describe("RoleService", () => {
create: vi.fn(),
delete: vi.fn(),
update: vi.fn(),
transaction: vi.fn(),
roleBelongsToTeam: vi.fn(),
getPermissions: vi.fn(),
};
service = new RoleService(mockRepository);
mockPermissionDiffService = {
calculateDiff: vi.fn(),
};
service = new RoleService(
mockRepository,
undefined, // permissionService is not mocked as it's not relevant for these tests
mockPermissionDiffService as unknown as PermissionDiffService
);
});
describe("createRole", () => {
@@ -153,9 +173,18 @@ describe("RoleService", () => {
});
describe("update", () => {
it("should update role permissions", async () => {
it("should update role permissions using permission diff", async () => {
const roleId = "role-id";
const permissions = ["eventType.create", "eventType.read"] as PermissionString[];
const existingPermissions = [{ id: "1", roleId, resource: "eventType", action: "delete" }];
const permissionChanges = {
toAdd: [
{ resource: "eventType", action: "create" },
{ resource: "eventType", action: "read" },
],
toRemove: [{ id: "1", roleId, resource: "eventType", action: "delete" }],
};
const role: Role = {
id: roleId,
@@ -172,14 +201,18 @@ describe("RoleService", () => {
};
mockRepository.findById.mockResolvedValueOnce(role);
mockRepository.getPermissions.mockResolvedValueOnce(existingPermissions);
mockPermissionDiffService.calculateDiff.mockReturnValueOnce(permissionChanges);
mockRepository.update.mockResolvedValueOnce(role);
const result = await service.update({ roleId, permissions });
expect(result).toBeDefined();
expect(mockRepository.update).toHaveBeenCalledWith(roleId, permissions, {
expect(mockPermissionDiffService.calculateDiff).toHaveBeenCalledWith(permissions, existingPermissions);
expect(mockRepository.update).toHaveBeenCalledWith(roleId, permissionChanges, {
color: undefined,
name: undefined,
});
expect(result).toBeDefined();
});
it("should throw error if role does not exist", async () => {
@@ -277,45 +310,18 @@ describe("RoleService", () => {
updatedAt: new Date(),
};
const mockTrx = {
selectFrom: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
executeTakeFirst: vi.fn().mockResolvedValue({ id: roleId }),
updateTable: vi.fn().mockReturnThis(),
set: vi.fn().mockReturnThis(),
execute: vi.fn().mockResolvedValue(undefined),
};
mockRepository.transaction.mockImplementationOnce((callback) => callback(mockRepository, mockTrx));
mockRepository.findById.mockResolvedValueOnce(role);
const result = await service.assignRoleToMember(roleId, membershipId);
expect(result).toEqual({ id: roleId });
expect(mockTrx.selectFrom).toHaveBeenCalledWith("Role");
expect(mockTrx.select).toHaveBeenCalledWith("id");
expect(mockTrx.where).toHaveBeenCalledWith("id", "=", roleId);
expect(mockTrx.executeTakeFirst).toHaveBeenCalled();
expect(mockTrx.updateTable).toHaveBeenCalledWith("Membership");
expect(mockTrx.set).toHaveBeenCalledWith({ customRoleId: roleId });
expect(mockTrx.where).toHaveBeenCalledWith("id", "=", membershipId);
expect(mockTrx.execute).toHaveBeenCalled();
expect(result).toEqual(role); // Check the full role object
});
it("should throw error if role does not exist", async () => {
const mockTrx = {
selectFrom: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
executeTakeFirst: vi.fn().mockResolvedValue(null),
};
mockRepository.transaction.mockImplementationOnce((callback) => callback(mockRepository, mockTrx));
mockRepository.findById.mockResolvedValueOnce(null);
await expect(service.assignRoleToMember(roleId, membershipId)).rejects.toThrow("Role not found");
expect(mockTrx.selectFrom).toHaveBeenCalledWith("Role");
expect(mockTrx.select).toHaveBeenCalledWith("id");
expect(mockTrx.where).toHaveBeenCalledWith("id", "=", roleId);
expect(mockTrx.executeTakeFirst).toHaveBeenCalled();
expect(mockRepository.findById).toHaveBeenCalledWith(roleId);
// Do NOT expect transaction methods to be called, since the repository returns null
});
});
});
@@ -0,0 +1,48 @@
import type { RolePermission } from "../domain/models/Role";
export type PermissionString = string;
export type ParsedPermission = { resource: string; action: string };
export class PermissionDiffService {
private parsePermission(permission: PermissionString): ParsedPermission {
const [resource, action] = permission.split(".");
return { resource, action };
}
private filterInternalPermissions(permissions: PermissionString[]): PermissionString[] {
return permissions.filter((permission) => {
const { action } = this.parsePermission(permission);
return action !== "_resource";
});
}
private createPermissionKey(permission: ParsedPermission): string {
return `${permission.resource}:${permission.action}`;
}
calculateDiff(newPermissions: PermissionString[], existingPermissions: RolePermission[]) {
const filteredPermissions = this.filterInternalPermissions(newPermissions);
// Create sets for comparison
const existingSet = new Set(existingPermissions.map((p) => this.createPermissionKey(p)));
const newSet = new Set(
filteredPermissions.map((p) => {
const parsed = this.parsePermission(p);
return this.createPermissionKey(parsed);
})
);
// Calculate permissions to add and remove
const toAdd = filteredPermissions
.map((p) => this.parsePermission(p))
.filter((p) => !existingSet.has(this.createPermissionKey(p)));
const toRemove = existingPermissions.filter((p) => !newSet.has(this.createPermissionKey(p)));
return {
toAdd,
toRemove,
};
}
}
+18 -27
View File
@@ -1,4 +1,4 @@
import kysely from "@calcom/kysely";
import db from "@calcom/prisma";
import type { MembershipRole } from "@calcom/prisma/enums";
import { RoleType as DomainRoleType } from "../domain/models/Role";
@@ -6,14 +6,14 @@ import type { CreateRoleData, UpdateRolePermissionsData } from "../domain/models
import type { IRoleRepository } from "../domain/repositories/IRoleRepository";
import { RoleRepository } from "../infrastructure/repositories/RoleRepository";
import { DEFAULT_ROLE_IDS } from "../lib/constants";
import { PermissionDiffService } from "./permission-diff.service";
import { PermissionService } from "./permission.service";
// These IDs must match the ones in the migration
export class RoleService {
constructor(
private readonly repository: IRoleRepository = new RoleRepository(),
private readonly permissionService: PermissionService = new PermissionService()
private readonly permissionService: PermissionService = new PermissionService(),
private readonly permissionDiffService: PermissionDiffService = new PermissionDiffService()
) {}
async createRole(data: CreateRoleData) {
@@ -37,19 +37,13 @@ export class RoleService {
}
async assignRoleToMember(roleId: string, membershipId: number) {
return this.repository.transaction(async (repo, trx) => {
const role = await trx.selectFrom("Role").select("id").where("id", "=", roleId).executeTakeFirst();
if (!role) throw new Error("Role not found");
// TODO: Move this to a MembershipRepository - bit difficult due to the trx record here.
await trx
.updateTable("Membership")
.set({ customRoleId: roleId })
.where("id", "=", membershipId)
.execute();
return role;
const role = await this.repository.findById(roleId);
if (!role) throw new Error("Role not found");
await db.membership.update({
where: { id: membershipId },
data: { customRoleId: roleId },
});
return role;
}
async getRolePermissions(roleId: string) {
@@ -58,12 +52,10 @@ export class RoleService {
}
async removeRoleFromMember(membershipId: number) {
// TODO: Move this to a MembershipRepository
await kysely
.updateTable("Membership")
.set({ customRoleId: null })
.where("id", "=", membershipId)
.execute();
await db.membership.update({
where: { id: membershipId },
data: { customRoleId: null },
});
}
async getRole(roleId: string) {
@@ -79,12 +71,10 @@ export class RoleService {
if (!role) {
throw new Error("Role not found");
}
// Don't allow deleting default roles
if (role.type === DomainRoleType.SYSTEM) {
throw new Error("Cannot delete default roles");
}
await this.repository.delete(roleId);
}
@@ -93,19 +83,20 @@ export class RoleService {
if (!role) {
throw new Error("Role not found");
}
// Don't allow updating default roles
if (role.type === DomainRoleType.SYSTEM) {
throw new Error("Cannot update default roles");
}
// Validate permissions
const validationResult = this.permissionService.validatePermissions(data.permissions);
if (!validationResult.isValid) {
throw new Error(validationResult.error || "Invalid permissions provided");
}
return this.repository.update(data.roleId, data.permissions, {
const existingPermissions = await this.repository.getPermissions(data.roleId);
const permissionChanges = this.permissionDiffService.calculateDiff(data.permissions, existingPermissions);
return this.repository.update(data.roleId, permissionChanges, {
color: data.updates?.color,
name: data.updates?.name,
});