feat: pbac - private teams (#23998)
* Add permission + migration for listMembersPrivate + depends on lsitMember * Add legacy list members PBAC private logic * Add checks in team pages + handler * add list memeber checks for private orgs * Add permision visibility scope to loading permissions * getMembers handler * fix fallback permission * Verify private teams work with private org * Apply suggestion from @eunjae-lee Co-authored-by: Eunjae Lee <hey@eunjae.dev> * Remove and private scope function and add param to old --------- Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
co-authored by
Eunjae Lee
parent
b17eab3aeb
commit
239e6085ba
+3
-1
@@ -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);
|
||||
|
||||
|
||||
+14
-3
@@ -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}
|
||||
/>
|
||||
))}{" "}
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>{" "}
|
||||
|
||||
+3
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+3
-1
@@ -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 === "*";
|
||||
|
||||
+15
-1
@@ -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<string, string | st
|
||||
|
||||
roleSearchParamsCache.parse(searchParams);
|
||||
|
||||
const [roles, rolePermissions] = await Promise.all([
|
||||
const [roles, rolePermissions, isPrivate] = await Promise.all([
|
||||
getCachedTeamRoles(session.user.org.id),
|
||||
getCachedResourcePermissions(session.user.id, session.user.org.id, Resource.Role),
|
||||
getCachedTeamPrivacy(session.user.org.id),
|
||||
]);
|
||||
|
||||
// NOTE: this approach of fetching permssions per resource does not account for fall back roles.
|
||||
@@ -108,6 +121,7 @@ const Page = async ({ searchParams }: { searchParams: Record<string, string | st
|
||||
}}
|
||||
initialSelectedRole={selectedRole}
|
||||
initialSheetOpen={isSheetOpen}
|
||||
isPrivate={isPrivate}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
+7
-1
@@ -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],
|
||||
|
||||
+17
-2
@@ -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}
|
||||
/>
|
||||
</SettingsHeader>
|
||||
);
|
||||
|
||||
+7
-1
@@ -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],
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<ResourceConfi
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter resources and actions based on scope
|
||||
* Filter resources and actions based on scope and optionally team privacy settings
|
||||
* @param scope The scope to filter by (Team or Organization)
|
||||
* @param isPrivate Whether the team/organization is private (optional)
|
||||
* @returns Filtered permission registry
|
||||
*/
|
||||
export const getPermissionsForScope = (scope: Scope): PermissionRegistry => {
|
||||
export const getPermissionsForScope = (scope: Scope, isPrivate?: boolean): PermissionRegistry => {
|
||||
const filteredRegistry: Partial<PermissionRegistry> = {};
|
||||
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",
|
||||
|
||||
+16
@@ -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
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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<boolean> => {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user