diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx index aadcc24c52..7ff65fc084 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/AdvancedPermissionGroup.tsx @@ -22,6 +22,7 @@ interface AdvancedPermissionGroupProps { onChange: (permissions: string[]) => void; disabled?: boolean; scope?: Scope; + isPrivate?: boolean; } const INTERNAL_DATAACCESS_KEY = "_resource"; @@ -32,10 +33,11 @@ export function AdvancedPermissionGroup({ onChange, disabled, scope = Scope.Organization, + isPrivate = false, }: AdvancedPermissionGroupProps) { const { t } = useLocale(); const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope); - const scopedRegistry = getPermissionsForScope(scope); + const scopedRegistry = getPermissionsForScope(scope, isPrivate); const resourceConfig = scopedRegistry[resource]; const [isExpanded, setIsExpanded] = useState(false); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx index 68360fdb30..b19013a4d6 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RoleSheet.tsx @@ -56,9 +56,17 @@ interface RoleSheetProps { onOpenChange: (open: boolean) => void; teamId: number; scope?: Scope; + isPrivate?: boolean; // Add isPrivate prop to control permission visibility } -export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Organization }: RoleSheetProps) { +export function RoleSheet({ + role, + open, + onOpenChange, + teamId, + scope = Scope.Organization, + isPrivate = false +}: RoleSheetProps) { const { t } = useLocale(); const router = useRouter(); const isEditing = Boolean(role); @@ -108,7 +116,8 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga const { isAdvancedMode, permissions, color } = form.watch(); const { filteredResources, scopedRegistry } = useMemo(() => { - const scopedRegistry = getPermissionsForScope(scope); + // Use privacy-aware filtering if we have privacy information + const scopedRegistry = getPermissionsForScope(scope, isPrivate); const filteredResources = Object.keys(scopedRegistry).filter((resource) => t( scopedRegistry[resource as Resource][CrudAction.All as keyof (typeof scopedRegistry)[Resource]] @@ -118,7 +127,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga .includes(searchQuery.toLowerCase()) ); return { filteredResources, scopedRegistry }; - }, [searchQuery, t, scope]); + }, [searchQuery, t, scope, isPrivate]); const createMutation = trpc.viewer.pbac.createRole.useMutation({ onSuccess: async () => { @@ -221,6 +230,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga onChange={(newPermissions) => form.setValue("permissions", newPermissions)} disabled={isSystemRole} scope={scope} + isPrivate={isPrivate} /> ))}{" "} @@ -245,6 +255,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga onChange={(newPermissions) => form.setValue("permissions", newPermissions)} disabled={isSystemRole} scope={scope} + isPrivate={isPrivate} /> ))} {" "} diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RolesList.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RolesList.tsx index 93409a937b..8ebde70433 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RolesList.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/RolesList.tsx @@ -48,6 +48,7 @@ interface RolesListProps { initialSheetOpen?: boolean; teamId: number; scope?: Scope; + isPrivate?: boolean; } export function RolesList({ @@ -57,6 +58,7 @@ export function RolesList({ initialSheetOpen, teamId, scope, + isPrivate, }: RolesListProps) { const { t } = useLocale(); const { isOpen, setIsOpen, selectedRoleId, setSelectedRoleId, handleSheetOpenChange } = useRoleStates( @@ -113,6 +115,7 @@ export function RolesList({ onOpenChange={handleSheetOpenChange} teamId={teamId} scope={scope} + isPrivate={isPrivate} /> ); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx index e658890c30..cc8e0aaebb 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/_components/SimplePermissionItem.tsx @@ -18,6 +18,7 @@ interface SimplePermissionItemProps { onChange: (permissions: string[]) => void; disabled?: boolean; scope?: Scope; + isPrivate?: boolean; } export function SimplePermissionItem({ @@ -26,10 +27,11 @@ export function SimplePermissionItem({ onChange, disabled, scope = Scope.Organization, + isPrivate = false, }: SimplePermissionItemProps) { const { t } = useLocale(); const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope); - const scopedRegistry = getPermissionsForScope(scope); + const scopedRegistry = getPermissionsForScope(scope, isPrivate); const registry = scopedRegistry || PERMISSION_REGISTRY; const isAllResources = resource === "*"; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx index 676a11bb02..97a0382843 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/roles/page.tsx @@ -44,6 +44,18 @@ const getCachedResourcePermissions = unstable_cache( { revalidate: 3600 } ); +const getCachedTeamPrivacy = unstable_cache( + async (teamId: number) => { + const team = await prisma.team.findUnique({ + where: { id: teamId }, + select: { isPrivate: true }, + }); + return team?.isPrivate ?? false; + }, + ["team-privacy"], + { revalidate: 3600 } +); + export const generateMetadata = async () => await _generateMetadata( (t) => t("roles_and_permissions"), @@ -69,9 +81,10 @@ const Page = async ({ searchParams }: { searchParams: Record ); diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx index 4d27a38b5b..97adac481d 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/members/page.tsx @@ -102,6 +102,7 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => { CustomAction.ChangeMemberRole, CustomAction.Remove, CustomAction.ListMembers, + CustomAction.ListMembersPrivate, CustomAction.Impersonate, ], fallbackRoles: { @@ -120,12 +121,17 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => { [CustomAction.Impersonate]: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER], }, + [CustomAction.ListMembersPrivate]: { + roles: fallbackRolesCanListMembers, + }, }, }); // Map specific permissions to member actions const memberPermissions = { - canListMembers: permissions[CustomAction.ListMembers], + canListMembers: team.isPrivate + ? permissions[CustomAction.ListMembersPrivate] + : permissions[CustomAction.ListMembers], canInvite: permissions[CustomAction.Invite], canChangeMemberRole: permissions[CustomAction.ChangeMemberRole], canRemove: permissions[CustomAction.Remove], diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx index 626148aeae..1568df8f48 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/teams/[id]/roles/page.tsx @@ -4,6 +4,7 @@ import { cookies, headers } from "next/headers"; import { notFound } from "next/navigation"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries"; import type { AppFlags } from "@calcom/features/flags/config"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper"; @@ -11,7 +12,6 @@ import { Resource, CrudAction, Scope } from "@calcom/features/pbac/domain/types/ import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { RoleService } from "@calcom/features/pbac/services/role.service"; import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; -import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries"; import { prisma } from "@calcom/prisma"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -51,6 +51,19 @@ const getCachedResourcePermissions = (userId: number, teamId: number, resource: { revalidate: 3600, tags: [`resource-permissions-${teamId}`] } ); +const getCachedTeamPrivacy = (teamId: number) => + unstable_cache( + async () => { + const team = await prisma.team.findUnique({ + where: { id: teamId }, + select: { isPrivate: true }, + }); + return team?.isPrivate ?? false; + }, + [`team-privacy-${teamId}`], + { revalidate: 3600, tags: [`team-privacy-${teamId}`] } + ); + const getCachedTeam = (teamId: string, userId: number) => unstable_cache( async () => { @@ -111,9 +124,10 @@ const Page = async ({ roleSearchParamsCache.parse(searchParams); - const [roles, rolePermissions] = await Promise.all([ + const [roles, rolePermissions, isPrivate] = await Promise.all([ getCachedTeamRoles(team.id)(), getCachedResourcePermissions(session.user.id, team.id, Resource.Role)(), + getCachedTeamPrivacy(team.id)(), ]); // NOTE: this approach of fetching permssions per resource does not account for fall back roles. @@ -153,6 +167,7 @@ const Page = async ({ initialSelectedRole={selectedRole} initialSheetOpen={isSheetOpen} scope={Scope.Team} + isPrivate={isPrivate} /> ); diff --git a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx index 588e5336df..c8b55bd3d1 100644 --- a/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/organizations/(org-user-only)/members/page.tsx @@ -70,6 +70,7 @@ const Page = async () => { userRole: session.user.org.role, actions: [ CustomAction.ListMembers, + CustomAction.ListMembersPrivate, CustomAction.Invite, CustomAction.ChangeMemberRole, CustomAction.Remove, @@ -79,6 +80,9 @@ const Page = async () => { [CustomAction.ListMembers]: { roles: fallbackRolesThatCanSeeMembers, }, + [CustomAction.ListMembersPrivate]: { + roles: fallbackRolesThatCanSeeMembers, + }, [CustomAction.Invite]: { roles: [MembershipRole.ADMIN, MembershipRole.OWNER], }, @@ -96,7 +100,9 @@ const Page = async () => { // Map specific permissions to member actions const memberPermissions = { - canListMembers: permissions[CustomAction.ListMembers], + canListMembers: org.isPrivate + ? permissions[CustomAction.ListMembersPrivate] + : permissions[CustomAction.ListMembers], canInvite: permissions[CustomAction.Invite], canChangeMemberRole: permissions[CustomAction.ChangeMemberRole], canRemove: permissions[CustomAction.Remove], diff --git a/apps/web/lib/team/[slug]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/getServerSideProps.tsx index 3aa548b170..07bed34c24 100644 --- a/apps/web/lib/team/[slug]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/getServerSideProps.tsx @@ -5,13 +5,13 @@ import { getOrganizationSettings, getVerifiedDomain, } from "@calcom/features/ee/organizations/lib/orgSettings"; +import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries"; import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { IS_CALCOM } from "@calcom/lib/constants"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import logger from "@calcom/lib/logger"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; -import { getTeamWithMembers } from "@calcom/features/ee/teams/lib/queries"; import slugify from "@calcom/lib/slugify"; import { stripMarkdown } from "@calcom/lib/stripMarkdown"; import prisma from "@calcom/prisma"; diff --git a/packages/features/pbac/domain/types/permission-registry.ts b/packages/features/pbac/domain/types/permission-registry.ts index 6c7608abf0..d4e61e2ce3 100644 --- a/packages/features/pbac/domain/types/permission-registry.ts +++ b/packages/features/pbac/domain/types/permission-registry.ts @@ -27,6 +27,7 @@ export enum CustomAction { Remove = "remove", ChangeMemberRole = "changeMemberRole", ListMembers = "listMembers", + ListMembersPrivate = "listMembersPrivate", ManageBilling = "manageBilling", ReadTeamBookings = "readTeamBookings", ReadOrgBookings = "readOrgBookings", @@ -46,6 +47,9 @@ export interface PermissionDetails { descriptionI18nKey: string; scope?: Scope[]; // Optional for backward compatibility dependsOn?: PermissionString[]; // Dependencies that must be enabled when this permission is enabled + visibleWhen?: { + teamPrivacy?: "private" | "public" | "both"; // Control visibility based on team privacy setting + }; } export type ResourceConfig = { @@ -122,12 +126,14 @@ export const filterResourceConfig = (config: ResourceConfig): Omit { +export const getPermissionsForScope = (scope: Scope, isPrivate?: boolean): PermissionRegistry => { const filteredRegistry: Partial = {}; + const teamPrivacy = isPrivate !== undefined ? (isPrivate ? "private" : "public") : undefined; Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => { const filteredConfig: ResourceConfig = { _resource: config._resource }; @@ -136,9 +142,17 @@ export const getPermissionsForScope = (scope: Scope): PermissionRegistry => { if (action === "_resource") return; const permissionDetails = details as PermissionDetails; - // If no scope is defined, include in both Team and Organization (backward compatibility) - // If scope is defined, only include if it matches the requested scope - if (!permissionDetails.scope || permissionDetails.scope.includes(scope)) { + + // Check scope + const scopeMatches = !permissionDetails.scope || permissionDetails.scope.includes(scope); + + // Check privacy visibility (only if isPrivate is provided) + const privacyMatches = teamPrivacy === undefined || + !permissionDetails.visibleWhen?.teamPrivacy || + permissionDetails.visibleWhen.teamPrivacy === "both" || + permissionDetails.visibleWhen.teamPrivacy === teamPrivacy; + + if (scopeMatches && privacyMatches) { filteredConfig[action as CrudAction | CustomAction] = permissionDetails; } }); @@ -282,6 +296,19 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { category: "team", i18nKey: "pbac_action_list_members", descriptionI18nKey: "pbac_desc_list_team_members", + visibleWhen: { + teamPrivacy: "public", // Only show for public teams + }, + }, + [CustomAction.ListMembersPrivate]: { + description: "List private team members", + category: "team", + i18nKey: "pbac_action_list_members", // Use same UI label as listMembers for consistency + descriptionI18nKey: "pbac_desc_list_team_members", // Use same description as listMembers + dependsOn: ["team.read"], + visibleWhen: { + teamPrivacy: "private", // Only show for private teams + }, }, [CustomAction.ChangeMemberRole]: { description: "Change role of team members", @@ -330,6 +357,20 @@ export const PERMISSION_REGISTRY: PermissionRegistry = { descriptionI18nKey: "pbac_desc_list_organization_members", scope: [Scope.Organization], dependsOn: ["organization.read"], + visibleWhen: { + teamPrivacy: "public", // Only show for public orgs + }, + }, + [CustomAction.ListMembersPrivate]: { + description: "List private organization members", + category: "org", + i18nKey: "pbac_action_list_members", // Same UI label as listMembers for consistency + descriptionI18nKey: "pbac_desc_list_organization_members", // Same description as listMembers + scope: [Scope.Organization], + dependsOn: ["organization.read"], + visibleWhen: { + teamPrivacy: "private", // Only show for private orgs + }, }, [CustomAction.Invite]: { description: "Invite organization members", diff --git a/packages/prisma/migrations/20250923085350_add_list_members_private_permissions/migration.sql b/packages/prisma/migrations/20250923085350_add_list_members_private_permissions/migration.sql new file mode 100644 index 0000000000..e74c8c7f88 --- /dev/null +++ b/packages/prisma/migrations/20250923085350_add_list_members_private_permissions/migration.sql @@ -0,0 +1,16 @@ +-- This permission allows admin users to view members of private teams and organizations + +INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt") +SELECT + gen_random_uuid(), 'admin_role', resource, action, NOW() +FROM ( + VALUES + -- Team listMembersPrivate permission + ('team', 'listMembersPrivate'), + + -- Organization listMembersPrivate permission + ('organization', 'listMembersPrivate') +) AS permissions(resource, action) +ON CONFLICT ("roleId", resource, action) DO NOTHING; + +-- Note: Owner role already has wildcard permissions (*.*) so it inherits all listMembersPrivate permissions automatically diff --git a/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts index 3df6206cb6..478253c59c 100644 --- a/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/getMembers.handler.ts @@ -1,4 +1,6 @@ +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "../../../types"; import type { TGetMembersInputSchema } from "./getMembers.schema"; @@ -16,9 +18,21 @@ export const getMembersHandler = async ({ input, ctx }: CreateOptions) => { if (!ctx.user.organizationId) return []; const isOrgPrivate = ctx.user.organization.isPrivate; - const isOrgAdmin = ctx.user.organization.isOrgAdmin; - if (isOrgPrivate && !isOrgAdmin) return []; + const permissionCheckService = new PermissionCheckService(); + + const hasPermissionToViewMembers = await permissionCheckService.checkPermission({ + userId: ctx.user.id, + teamId: ctx.user.organizationId, + permission: ctx.user.organization.isPrivate + ? "organization.listMembersPrivate" + : "organization.listMembers", + fallbackRoles: ctx.user.organization.isPrivate + ? [MembershipRole.ADMIN, MembershipRole.OWNER] + : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }); + + if (!hasPermissionToViewMembers) return []; const teamQuery = await prisma.team.findUnique({ where: { diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts index dfaacf2fa3..1b09e367ec 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts @@ -8,6 +8,7 @@ import { listMembersHandler } from "./listMembers.handler"; vi.mock("@calcom/prisma", () => ({ prisma, + default: prisma, // Add default export for db })); // Mock FeaturesRepository @@ -22,9 +23,18 @@ vi.mock("@calcom/features/flags/features.repository", () => ({ vi.mock("@calcom/features/pbac/lib/resource-permissions", () => ({ getSpecificPermissions: vi.fn().mockResolvedValue({ listMembers: true, + listMembersPrivate: true, }), })); +// Mock PermissionCheckService +const mockCheckPermission = vi.fn().mockResolvedValue(true); +vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({ + PermissionCheckService: vi.fn().mockImplementation(() => ({ + checkPermission: mockCheckPermission, + })), +})); + // Mock UserRepository vi.mock("@calcom/lib/server/repository/user", () => ({ UserRepository: vi.fn().mockImplementation(() => ({ @@ -65,6 +75,8 @@ describe("listMembersHandler", () => { prisma.membership.findMany.mockResolvedValue([]); prisma.membership.count.mockResolvedValue(0); prisma.membership.findFirst.mockResolvedValue({ role: "ADMIN" }); + // Mock team.findUnique to return only isPrivate field since that's what the handler selects + prisma.team.findUnique.mockResolvedValue({ isPrivate: false } as any); }); it("should filter by customRoleId when PBAC is enabled", async () => { @@ -234,4 +246,72 @@ describe("listMembersHandler", () => { }) ); }); + + it("should check listMembersPrivate permission for private organizations", async () => { + // Clear previous calls + mockCheckPermission.mockClear(); + mockCheckPermission.mockResolvedValue(true); + + // Mock private organization + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma.team.findUnique.mockResolvedValue({ isPrivate: true } as any); + + const privateOrgUser = { + ...mockUser, + organization: { + ...mockUser.organization, + isPrivate: true, + }, + }; + + await listMembersHandler({ + ctx: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + user: privateOrgUser as any, + }, + input: { + limit: 25, + offset: 0, + filters: [], + }, + }); + + // Verify that checkPermission was called with listMembersPrivate + expect(mockCheckPermission).toHaveBeenCalledWith( + expect.objectContaining({ + permission: "organization.listMembersPrivate", + fallbackRoles: ["ADMIN", "OWNER"], + }) + ); + }); + + it("should check listMembers permission for public organizations", async () => { + // Clear previous calls + mockCheckPermission.mockClear(); + mockCheckPermission.mockResolvedValue(true); + + // Mock public organization (default) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prisma.team.findUnique.mockResolvedValue({ isPrivate: false } as any); + + await listMembersHandler({ + ctx: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + user: mockUser as any, + }, + input: { + limit: 25, + offset: 0, + filters: [], + }, + }); + + // Verify that checkPermission was called with listMembers + expect(mockCheckPermission).toHaveBeenCalledWith( + expect.objectContaining({ + permission: "organization.listMembers", + fallbackRoles: ["MEMBER", "ADMIN", "OWNER"], + }) + ); + }); }); diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index c5e8196266..dea80c632c 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -3,6 +3,7 @@ import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data- import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry"; import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; @@ -87,23 +88,20 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not a member of this organization." }); } - // Check PBAC permissions for listing organization members - const permissions = await getSpecificPermissions({ + const permissionCheckService = new PermissionCheckService(); + + const hasPermission = await permissionCheckService.checkPermission({ userId: ctx.user.id, teamId: organizationId, - resource: Resource.Organization, - userRole: membership.role, - actions: [CustomAction.ListMembers], - fallbackRoles: { - [CustomAction.ListMembers]: { - roles: ctx.user.organization.isPrivate - ? [MembershipRole.ADMIN, MembershipRole.OWNER] - : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], - }, - }, + permission: ctx.user.organization.isPrivate + ? "organization.listMembersPrivate" + : "organization.listMembers", + fallbackRoles: ctx.user.organization.isPrivate + ? [MembershipRole.ADMIN, MembershipRole.OWNER] + : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], }); - if (!permissions[CustomAction.ListMembers]) { + if (!hasPermission) { return { canUserGetMembers: false, rows: [], diff --git a/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts index 51f3e01d0f..24df19a53f 100644 --- a/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/legacyListMembers.handler.ts @@ -1,3 +1,6 @@ +import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry"; +import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { UserRepository } from "@calcom/lib/server/repository/user"; import type { PrismaClient } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; @@ -16,14 +19,18 @@ type ListMembersOptions = { export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => { const { prisma } = ctx; - const { isOrgAdmin } = ctx.user.organization; - const hasPermsToView = !ctx.user.organization.isPrivate || isOrgAdmin; + const orgId = ctx.user.organizationId; - if (!hasPermsToView) { - return { - members: [], - nextCursor: undefined, - }; + // Check PBAC permissions for the organization if it's private + if (orgId) { + const hasPermsToView = await checkCanAccessOrgMembers(ctx, orgId); + + if (!hasPermsToView) { + return { + members: [], + nextCursor: undefined, + }; + } } const limit = input.limit ?? 10; @@ -129,4 +136,30 @@ export const legacyListMembers = async ({ ctx, input }: ListMembersOptions) => { }; }; +const checkCanAccessOrgMembers = async (ctx: ListMembersOptions["ctx"], orgId: number): Promise => { + const { prisma } = ctx; + + // Get organization info to verify it's private + const org = await prisma.team.findUnique({ + where: { id: orgId }, + select: { isPrivate: true }, + }); + + if (!org) return false; + + // Check PBAC permissions for listing members + const permissionCheckService = new PermissionCheckService(); + + const hasPermission = await permissionCheckService.checkPermission({ + userId: ctx.user.id, + teamId: orgId, + permission: org.isPrivate ? "organization.listMembersPrivate" : "organization.listMembers", + fallbackRoles: org.isPrivate + ? [MembershipRole.ADMIN, MembershipRole.OWNER] + : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], + }); + + return hasPermission; +}; + export default legacyListMembers; diff --git a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts index b0daff94fa..dbcb26670b 100644 --- a/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/listMembers.handler.ts @@ -1,5 +1,10 @@ -import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry"; +import { + Resource, + CustomAction, + PermissionString, +} from "@calcom/features/pbac/domain/types/permission-registry"; import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions"; +import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory"; import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client"; import { TeamRepository } from "@calcom/lib/server/repository/team"; @@ -157,37 +162,22 @@ const checkCanAccessMembers = async (ctx: ListMembersHandlerOptions["ctx"], team if (!team) return false; - // Get user's membership in the team - const membership = await prisma.membership.findFirst({ - where: { - teamId, - userId: ctx.user.id, - accepted: true, - }, - }); - - if (!membership) return false; - // Determine the resource type based on whether this is an org or team const resource = isTargetingOrg ? Resource.Organization : Resource.Team; + const targetAction = team.isPrivate ? CustomAction.ListMembersPrivate : CustomAction.ListMembers; + const permissionString = `${resource}.${targetAction}` as PermissionString; // Check PBAC permissions for listing members - const permissions = await getSpecificPermissions({ + const permissionCheckService = new PermissionCheckService(); + + return permissionCheckService.checkPermission({ userId: ctx.user.id, teamId: teamId, - resource: resource, - userRole: membership.role, - actions: [CustomAction.ListMembers], - fallbackRoles: { - [CustomAction.ListMembers]: { - roles: team.isPrivate - ? [MembershipRole.ADMIN, MembershipRole.OWNER] - : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], - }, - }, + permission: permissionString, + fallbackRoles: team.isPrivate + ? [MembershipRole.ADMIN, MembershipRole.OWNER] + : [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER], }); - - return permissions[CustomAction.ListMembers]; }; export default listMembersHandler;