refactor: migrate MembershipRole usages to PBAC permission checks (#26871)

* refactor: migrate MembershipRole usages to PBAC permission checks

- Refactor get.handler.ts to use PermissionCheckService for canUpdateTeams
- Refactor checkForInvalidAppCredentials.ts to use getTeamIdsWithPermission
- Refactor outOfOffice.utils.ts to use checkPermission for ooo.update
- Refactor checkIfOrgNeedsUpgrade.handler.ts to use organization.manageBilling
- Refactor getActiveOnOptions.handler.ts to use eventType.update permission
- Refactor WorkflowRepository.ts to use workflow.update permission
- Refactor organization.tsx to use team.update permission
- Refactor getEventTypesByViewer.ts to use eventType.update permission
- Refactor getPublicEvent.ts to use team.read permission for private teams
- Update CreateNewOutOfOfficeEntryButton.tsx to use canUpdateOOO prop

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix: use organization.update permission instead of team.update for org management

Co-Authored-By: sean@cal.com <Sean@brydon.io>

* fix: rename teamsWithEventTypeManagePermission to teamsWithEventTypeUpdatePermission

Renamed variable to match the permission string being used (eventType.update)

Co-Authored-By: sean@cal.com <Sean@brydon.io>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2026-01-15 15:59:00 +01:00
committed by GitHub
co-authored by sean@cal.com <Sean@brydon.io> sean@cal.com <Sean@brydon.io> sean@cal.com <Sean@brydon.io> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 3a8fe8c7af
commit 9ea71d2ba5
10 changed files with 111 additions and 87 deletions
@@ -23,7 +23,7 @@ const CreateNewOutOfOfficeEntryButton = ({
const me = useMeQuery();
const { data: orgData } = trpc.viewer.organizations.listCurrent.useQuery();
const isOrgAdminOrOwner = orgData && checkAdminOrOwner(orgData.user.role);
const hasTeamOOOAdminAccess = isOrgAdminOrOwner || me?.data?.isTeamAdminOrOwner;
const hasTeamOOOAdminAccess = isOrgAdminOrOwner || me?.data?.canUpdateTeams;
const params = useCompatSearchParams();
const selectedTab = params?.get("type") ?? OutOfOfficeTab.MINE;
@@ -2,6 +2,7 @@ import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { MembershipRole } from "@calcom/prisma/enums";
export const getServerSideProps = async ({ req }: GetServerSidePropsContext) => {
@@ -24,19 +25,15 @@ export const getServerSideProps = async ({ req }: GetServerSidePropsContext) =>
} as const;
}
// Check if logged in user has OWNER/ADMIN role in organization
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId: session?.user.id,
teamId: session?.user.profile.organizationId,
},
},
select: {
role: true,
},
const permissionCheckService = new PermissionCheckService();
const canManageOrganization = await permissionCheckService.checkPermission({
userId: session.user.id,
teamId: session.user.profile.organizationId,
permission: "organization.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
if (!membership?.role || membership?.role === MembershipRole.MEMBER) {
if (!canManageOrganization) {
return {
notFound: true,
} as const;
@@ -7,6 +7,7 @@ import { deleteScheduledSMSReminder } from "@calcom/ee/workflows/lib/reminders/s
import type { WorkflowListType as WorkflowType } from "@calcom/ee/workflows/lib/types";
import type { WorkflowStep } from "@calcom/ee/workflows/lib/types";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
@@ -242,6 +243,15 @@ export class WorkflowRepository {
const filtered = filters && hasFilter(filters);
const permissionCheckService = new PermissionCheckService();
const teamIdsWithWorkflowUpdatePermission = userId
? await permissionCheckService.getTeamIdsWithPermission({
userId,
permission: "workflow.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
})
: [];
const allWorkflows = await prisma.workflow.findMany({
where: {
OR: [
@@ -273,9 +283,9 @@ export class WorkflowRepository {
if (!filtered) {
const workflowsWithReadOnly: WorkflowType[] = allWorkflows.map((workflow) => {
const readOnly = !!workflow.team?.members?.find(
(member) => member.userId === userId && member.role === MembershipRole.MEMBER
);
const readOnly = workflow.teamId
? !teamIdsWithWorkflowUpdatePermission.includes(workflow.teamId)
: false;
return { readOnly, isOrg: workflow.team?.isOrganization ?? false, ...workflow };
});
@@ -325,9 +335,9 @@ export class WorkflowRepository {
});
const workflowsWithReadOnly: WorkflowType[] = filteredWorkflows.map((workflow) => {
const readOnly = !!workflow.team?.members?.find(
(member) => member.userId === userId && member.role === MembershipRole.MEMBER
);
const readOnly = workflow.teamId
? !teamIdsWithWorkflowUpdatePermission.includes(workflow.teamId)
: false;
return { readOnly, isOrg: workflow.team?.isOrganization ?? false, ...workflow };
});
@@ -52,13 +52,19 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo
shouldListUserEvents = true;
}
// Get teams where user has eventType.read permission for PBAC readonly check
const permissionCheckService = new PermissionCheckService();
const teamsWithEventTypeReadPermission = await permissionCheckService.getTeamIdsWithPermission({
userId: user.id,
permission: "eventType.read",
fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
});
const [teamsWithEventTypeReadPermission, teamsWithEventTypeUpdatePermission] = await Promise.all([
permissionCheckService.getTeamIdsWithPermission({
userId: user.id,
permission: "eventType.read",
fallbackRoles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
}),
permissionCheckService.getTeamIdsWithPermission({
userId: user.id,
permission: "eventType.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
}),
]);
const eventTypeRepo = new EventTypeRepository(prisma);
const [profileMemberships, profileEventTypes] = await Promise.all([
@@ -295,7 +301,7 @@ export const getEventTypesByViewer = async (user: User, filters?: Filters, forRo
return res;
})
.filter((evType) =>
membership.role === MembershipRole.MEMBER
!teamsWithEventTypeUpdatePermission.includes(team.id)
? evType.schedulingType !== SchedulingType.MANAGED
: true
)
@@ -7,7 +7,9 @@ import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/
import { getBookerBaseUrlSync } from "@calcom/features/ee/organizations/lib/getBookerBaseUrlSync";
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
import { getDefaultEvent, getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { MembershipRole } from "@calcom/prisma/enums";
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
@@ -517,25 +519,27 @@ export const getPublicEvent = async (
length: eventWithUserProfiles.length,
});
}
const isTeamAdminOrOwner = await prisma.membership.findFirst({
where: {
userId: currentUserId ?? -1,
teamId: event.teamId ?? -1,
accepted: true,
role: { in: ["ADMIN", "OWNER"] },
},
});
let canViewPrivateTeamMembers = false;
if (currentUserId && event.teamId) {
const permissionCheckService = new PermissionCheckService();
canViewPrivateTeamMembers = await permissionCheckService.checkPermission({
userId: currentUserId,
teamId: event.teamId,
permission: "team.read",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
const isOrgAdminOrOwner = await prisma.membership.findFirst({
where: {
userId: currentUserId ?? -1,
teamId: event.team?.parentId ?? -1,
accepted: true,
role: { in: ["ADMIN", "OWNER"] },
},
});
if (!canViewPrivateTeamMembers && event.team?.parentId) {
canViewPrivateTeamMembers = await permissionCheckService.checkPermission({
userId: currentUserId,
teamId: event.team.parentId,
permission: "team.read",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
}
}
if (event.team?.isPrivate && !isTeamAdminOrOwner && !isOrgAdminOrOwner) {
if (event.team?.isPrivate && !canViewPrivateTeamMembers) {
users = [];
}
@@ -1,5 +1,6 @@
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import { PrismaRoutingFormRepository } from "@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
@@ -55,12 +56,14 @@ const fetchEventTypeGroups = async ({
parentOrgHasLockedEventTypes,
skipEventTypes,
teamId,
teamIdsWithEventTypeUpdatePermission,
}: {
ctx: { user: NonNullable<TrpcSessionUser>; prisma: PrismaClient };
profile: NonNullable<Awaited<ReturnType<typeof ProfileRepository.findByUpIdWithAuth>>>;
parentOrgHasLockedEventTypes: boolean | undefined;
skipEventTypes: boolean;
teamId?: number;
teamIdsWithEventTypeUpdatePermission: number[];
}): Promise<EventTypeGroup[]> => {
const user = ctx.user;
const userProfile = ctx.user.profile;
@@ -132,12 +135,11 @@ const fetchEventTypeGroups = async ({
metadata: teamMetadataSchema.parse(membership.team.metadata),
};
const canUpdateEventTypes = teamIdsWithEventTypeUpdatePermission.includes(team.id);
const eventTypes = team.eventTypes
?.filter((evType) => evType.userId === null || evType.userId === user.id)
?.filter((evType) =>
membership.role === MembershipRole.MEMBER
? evType.schedulingType !== SchedulingType.MANAGED
: true
!canUpdateEventTypes ? evType.schedulingType !== SchedulingType.MANAGED : true
);
return {
@@ -222,12 +224,20 @@ export const getActiveOnOptions = async ({ ctx, input }: GetActiveOnOptions) =>
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
const permissionCheckService = new PermissionCheckService();
const teamIdsWithEventTypeUpdatePermission = await permissionCheckService.getTeamIdsWithPermission({
userId: user.id,
permission: "eventType.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
const eventTypeGroups = await fetchEventTypeGroups({
ctx,
profile,
parentOrgHasLockedEventTypes,
skipEventTypes: shouldSkipEventTypes,
teamId,
teamIdsWithEventTypeUpdatePermission,
});
const teamOptions = await fetchTeamOptions({
@@ -1,4 +1,5 @@
import { getAppFromSlug } from "@calcom/app-store/utils";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { type InvalidAppCredentialBannerProps } from "@calcom/features/users/types/invalidAppCredentials";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -13,21 +14,16 @@ type checkInvalidAppCredentialsOptions = {
export const checkInvalidAppCredentials = async ({ ctx }: checkInvalidAppCredentialsOptions) => {
const userId = ctx.user.id;
// First get the teams where user is admin/owner
const userTeamIds = await prisma.membership.findMany({
where: {
userId: userId,
accepted: true,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
select: {
teamId: true,
},
const permissionCheckService = new PermissionCheckService();
const userTeamIds = await permissionCheckService.getTeamIdsWithPermission({
userId,
permission: "team.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
const apps = await prisma.credential.findMany({
where: {
OR: [{ userId }, { teamId: { in: userTeamIds.map((membership) => membership.teamId) } }],
OR: [{ userId }, { teamId: { in: userTeamIds } }],
invalid: true,
},
select: {
@@ -1,5 +1,6 @@
import type { Session } from "next-auth";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
@@ -94,17 +95,13 @@ export const getHandler = async ({ ctx, input }: MeOptions) => {
organizationSettings: user?.profile?.organization?.organizationSettings,
};
const isTeamAdminOrOwner =
(await prisma.membership.findFirst({
where: {
userId: user.id,
accepted: true,
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
},
select: {
id: true,
},
})) !== null;
const permissionCheckService = new PermissionCheckService();
const teamsWithWritePermission = await permissionCheckService.getTeamIdsWithPermission({
userId: user.id,
permission: "team.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
const canUpdateTeams = teamsWithWritePermission.length > 0;
return {
id: user.id,
@@ -145,6 +142,6 @@ export const getHandler = async ({ ctx, input }: MeOptions) => {
secondaryEmails,
isPremium: userMetadataPrased?.isPremium,
...(passwordAdded ? { passwordAdded } : {}),
isTeamAdminOrOwner,
canUpdateTeams,
};
};
@@ -1,21 +1,18 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
export const isAdminForUser = async (adminUserId: number, memberUserId: number) => {
const adminTeams = await prisma.membership.findMany({
where: {
userId: adminUserId,
accepted: true,
role: {
in: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
select: {
teamId: true,
},
const permissionCheckService = new PermissionCheckService();
const adminTeamIds = await permissionCheckService.getTeamIdsWithPermission({
userId: adminUserId,
permission: "ooo.update",
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
});
const adminTeamIds = adminTeams?.map((team) => team.teamId);
if (adminTeamIds.length === 0) {
return false;
}
const member = await prisma.membership.findFirst({
where: {
@@ -1,3 +1,4 @@
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -14,16 +15,22 @@ type GetUpgradeableOptions = {
export async function checkIfOrgNeedsUpgradeHandler({ ctx }: GetUpgradeableOptions) {
if (!IS_TEAM_BILLING_ENABLED) return [];
// Get all teams/orgs where the user is an owner
const permissionCheckService = new PermissionCheckService();
const teamIdsWithBillingPermission = await permissionCheckService.getTeamIdsWithPermission({
userId: ctx.user.id,
permission: "organization.manageBilling",
fallbackRoles: [MembershipRole.OWNER],
});
if (teamIdsWithBillingPermission.length === 0) return [];
let teams = await prisma.membership.findMany({
where: {
user: {
id: ctx.user.id,
},
role: MembershipRole.OWNER,
userId: ctx.user.id,
teamId: { in: teamIdsWithBillingPermission },
team: {
isPlatform: false,
parentId: null, // Since ORGS relay on their parent's subscription, we don't need to return them
parentId: null,
},
},
include: {