feat: PBAC on team members page + org members page to use specific permissi… (#23004)

* PBAC on team members page + org members page to use specific permissions with fallbacks

* Implement checks on edit sheet fetch to check for permisisons

* WIP permission fixes

* fix tests

* WIP

* fix edit mode perms

* fix remove and list view permissions

* fix add

* feat: add pbac permissions for impersonation (#23035)

* registery + migration + i18n

* UI for can impersonate

* impersonation backend

* remove permission from db query

* add missing prisma import

* add resend invitation and edit mode checks for delete/changeRole

* fix canChangeRole

* canChangeRole gate

* fix permission logic

* fix depends on

* push migration

* fix migration

* fix scoped advanced permissions

* use listMembers instead of invite proxy

* remove isOrgAdmin check to rely on pbac

* wait for session to load before navigating

* use event-types testId to ensure session loaded

* use profile page instead of eventTypes

* use correct roles

* fix wait for session

* correct orgs private isFallBackRoles

* set a timeout on waiting for session

* remove adminOrOwner check since its in fallpack

---------

Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
sean-brydon
2025-09-02 12:52:45 -03:00
committed by GitHub
co-authored by Volnei Munhoz Eunjae Lee
parent 2dbd0b3832
commit 75465ac4bf
28 changed files with 830 additions and 214 deletions
@@ -181,14 +181,18 @@ const organizationAdminKeys = [
"delegation_credential",
];
export interface SettingsPermissions {
canViewRoles?: boolean;
}
const useTabs = ({
isDelegationCredentialEnabled,
isPbacEnabled,
canViewRoles,
permissions,
}: {
isDelegationCredentialEnabled: boolean;
isPbacEnabled: boolean;
canViewRoles?: boolean;
permissions?: SettingsPermissions;
}) => {
const session = useSession();
const { data: user } = trpc.viewer.me.get.useQuery({ includePasswordAdded: true });
@@ -227,7 +231,7 @@ const useTabs = ({
// Add pbac menu item only if feature flag is enabled AND user has permission to view roles
// This prevents showing the menu item when user has no organization permissions
if (isPbacEnabled && canViewRoles) {
if (isPbacEnabled && permissions?.canViewRoles) {
newArray.push({
name: "roles_and_permissions",
href: "/settings/organizations/roles",
@@ -294,7 +298,7 @@ interface SettingsSidebarContainerProps {
navigationIsOpenedOnMobile?: boolean;
bannersHeight?: number;
teamFeatures?: Record<number, TeamFeatures>;
canViewRoles?: boolean;
permissions?: SettingsPermissions;
}
const TeamRolesNavItem = ({
@@ -487,7 +491,7 @@ const SettingsSidebarContainer = ({
navigationIsOpenedOnMobile,
bannersHeight,
teamFeatures,
canViewRoles,
permissions,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const orgBranding = useOrgBranding();
@@ -517,7 +521,7 @@ const SettingsSidebarContainer = ({
const tabsWithPermissions = useTabs({
isDelegationCredentialEnabled,
isPbacEnabled,
canViewRoles,
permissions,
});
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
@@ -792,13 +796,13 @@ export type SettingsLayoutProps = {
children: React.ReactNode;
containerClassName?: string;
teamFeatures?: Record<number, TeamFeatures>;
canViewRoles?: boolean;
permissions?: SettingsPermissions;
} & ComponentProps<typeof Shell>;
export default function SettingsLayoutAppDirClient({
children,
teamFeatures,
canViewRoles,
permissions,
...rest
}: SettingsLayoutProps) {
const pathname = usePathname();
@@ -833,7 +837,7 @@ export default function SettingsLayoutAppDirClient({
sideContainerOpen={sideContainerOpen}
setSideContainerOpen={setSideContainerOpen}
teamFeatures={teamFeatures}
canViewRoles={canViewRoles}
permissions={permissions}
/>
}
drawerState={state}
@@ -856,7 +860,7 @@ type SidebarContainerElementProps = {
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
teamFeatures?: Record<number, TeamFeatures>;
canViewRoles?: boolean;
permissions?: SettingsPermissions;
};
const SidebarContainerElement = ({
@@ -864,7 +868,7 @@ const SidebarContainerElement = ({
bannersHeight,
setSideContainerOpen,
teamFeatures,
canViewRoles,
permissions,
}: SidebarContainerElementProps) => {
const { t } = useLocale();
return (
@@ -881,7 +885,7 @@ const SidebarContainerElement = ({
navigationIsOpenedOnMobile={sideContainerOpen}
bannersHeight={bannersHeight}
teamFeatures={teamFeatures}
canViewRoles={canViewRoles}
permissions={permissions}
/>
</>
);
@@ -67,7 +67,11 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
return (
<>
<SettingsLayoutAppDirClient {...props} teamFeatures={teamFeatures ?? {}} canViewRoles={canViewRoles} />
<SettingsLayoutAppDirClient
{...props}
teamFeatures={teamFeatures ?? {}}
permissions={{ canViewRoles }}
/>
</>
);
}
@@ -3,7 +3,11 @@
import { useState } from "react";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import {
Scope,
CrudAction,
getPermissionsForScope,
} from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Checkbox, Label } from "@calcom/ui/components/form";
@@ -17,6 +21,7 @@ interface AdvancedPermissionGroupProps {
selectedPermissions: string[];
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
}
const INTERNAL_DATAACCESS_KEY = "_resource";
@@ -26,21 +31,31 @@ export function AdvancedPermissionGroup({
selectedPermissions,
onChange,
disabled,
scope = Scope.Organization,
}: AdvancedPermissionGroupProps) {
const { t } = useLocale();
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions();
const resourceConfig = PERMISSION_REGISTRY[resource];
const { toggleSinglePermission, toggleResourcePermissionLevel } = usePermissions(scope);
const scopedRegistry = getPermissionsForScope(scope);
const resourceConfig = scopedRegistry[resource];
const [isExpanded, setIsExpanded] = useState(false);
const isAllResources = resource === "*";
// Early return if resource is not in the scoped registry (and not the special "*" resource)
if (!isAllResources && !resourceConfig) {
return null;
}
const allResourcesSelected = selectedPermissions.includes("*.*");
// Get all possible permissions for this resource
const allPermissions = isAllResources
? ["*.*"]
: Object.entries(resourceConfig)
: resourceConfig
? Object.entries(resourceConfig)
.filter(([action]) => action !== INTERNAL_DATAACCESS_KEY)
.map(([action]) => `${resource}.${action}`);
.map(([action]) => `${resource}.${action}`)
: [];
// Check if all permissions for this resource are selected
const isAllSelected = isAllResources
@@ -99,7 +114,7 @@ export function AdvancedPermissionGroup({
<span
className="text-default cursor-pointer text-sm font-medium leading-none"
onClick={() => setIsExpanded(!isExpanded)}>
{t(resourceConfig._resource?.i18nKey || "")}
{t(resourceConfig?._resource?.i18nKey || "")}
</span>
<span
className="text-muted cursor-pointer text-sm font-medium leading-none"
@@ -113,56 +128,57 @@ export function AdvancedPermissionGroup({
className="bg-default border-muted m-1 flex flex-col gap-2.5 rounded-xl border p-3"
onClick={(e) => e.stopPropagation()} // Stop clicks in the permission list from affecting parent
>
{Object.entries(resourceConfig).map(([action, actionConfig]) => {
const permission = `${resource}.${action}`;
{resourceConfig &&
Object.entries(resourceConfig).map(([action, actionConfig]) => {
const permission = `${resource}.${action}`;
if (action === INTERNAL_DATAACCESS_KEY) {
return null;
}
if (action === INTERNAL_DATAACCESS_KEY) {
return null;
}
const isChecked = selectedPermissions.includes(permission);
const isReadPermission = action === CrudAction.Read;
const isAutoEnabled = isReadAutoEnabled(action);
const isChecked = selectedPermissions.includes(permission);
const isReadPermission = action === CrudAction.Read;
const isAutoEnabled = isReadAutoEnabled(action);
return (
<div key={action} className="flex items-center">
<Checkbox
id={permission}
checked={isChecked}
className="mr-2"
onCheckedChange={(checked) => {
if (!disabled) {
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
}
}}
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
disabled={disabled}
/>
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
>
<Label htmlFor={permission} className="mb-0">
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
{t(actionConfig?.i18nKey || "")}
return (
<div key={action} className="flex items-center">
<Checkbox
id={permission}
checked={isChecked}
className="mr-2"
onCheckedChange={(checked) => {
if (!disabled) {
onChange(toggleSinglePermission(permission, !!checked, selectedPermissions));
}
}}
onClick={(e) => e.stopPropagation()} // Stop checkbox clicks from affecting parent
disabled={disabled}
/>
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()} // Stop label clicks from affecting parent
>
<Label htmlFor={permission} className="mb-0">
<span className={classNames(isAutoEnabled && "text-muted-foreground")}>
{t(actionConfig?.i18nKey || "")}
</span>
</Label>
<span className="text-sm text-gray-500">
{t(
actionConfig && "descriptionI18nKey" in actionConfig
? actionConfig.descriptionI18nKey
: ""
)}
</span>
</Label>
<span className="text-sm text-gray-500">
{t(
actionConfig && "descriptionI18nKey" in actionConfig
? actionConfig.descriptionI18nKey
: ""
{isAutoEnabled && (
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
<Icon name="info" className="text-muted-foreground h-3 w-3" />
</Tooltip>
)}
</span>
{isAutoEnabled && (
<Tooltip content={t("read_permission_auto_enabled_tooltip")}>
<Icon name="info" className="text-muted-foreground h-3 w-3" />
</Tooltip>
)}
</div>
</div>
</div>
);
})}{" "}
);
})}{" "}
</div>
)}
</div>
@@ -145,10 +145,6 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
});
const onSubmit = (values: FormValues) => {
// Store the color in localStorage
const roleKey = isEditing && role ? role.id : `new_role_${values.name}`;
localStorage.setItem(`role_color_${roleKey}`, values.color);
if (isEditing && role) {
updateMutation.mutate({
teamId,
@@ -224,6 +220,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
selectedPermissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
/>
))}
</div>
@@ -247,6 +244,7 @@ export function RoleSheet({ role, open, onOpenChange, teamId, scope = Scope.Orga
permissions={permissions}
onChange={(newPermissions) => form.setValue("permissions", newPermissions)}
disabled={isSystemRole}
scope={scope}
/>
))}
</div>
@@ -1,6 +1,6 @@
"use client";
import type { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import type { Resource, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ToggleGroup } from "@calcom/ui/components/form";
@@ -13,6 +13,7 @@ interface SimplePermissionItemProps {
permissions: string[];
onChange: (permissions: string[]) => void;
disabled?: boolean;
scope?: Scope;
}
export function SimplePermissionItem({
@@ -20,9 +21,10 @@ export function SimplePermissionItem({
permissions,
onChange,
disabled,
scope,
}: SimplePermissionItemProps) {
const { t } = useLocale();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions();
const { getResourcePermissionLevel, toggleResourcePermissionLevel } = usePermissions(scope);
const isAllResources = resource === "*";
const options = isAllResources
@@ -1,5 +1,8 @@
import { CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PERMISSION_REGISTRY } from "@calcom/features/pbac/domain/types/permission-registry";
import { CrudAction, Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import {
PERMISSION_REGISTRY,
getPermissionsForScope,
} from "@calcom/features/pbac/domain/types/permission-registry";
import {
getTransitiveDependencies,
getTransitiveDependents,
@@ -18,10 +21,11 @@ interface UsePermissionsReturn {
toggleSinglePermission: (permission: string, enabled: boolean, currentPermissions: string[]) => string[];
}
export function usePermissions(): UsePermissionsReturn {
export function usePermissions(scope: Scope = Scope.Organization): UsePermissionsReturn {
const getAllPossiblePermissions = () => {
const permissions: string[] = [];
Object.entries(PERMISSION_REGISTRY).forEach(([resource, config]) => {
const scopedRegistry = getPermissionsForScope(scope);
Object.entries(scopedRegistry).forEach(([resource, config]) => {
if (resource !== "*") {
Object.keys(config)
.filter((action) => !action.startsWith("_"))
@@ -34,7 +38,8 @@ export function usePermissions(): UsePermissionsReturn {
};
const hasAllPermissions = (permissions: string[]) => {
return Object.entries(PERMISSION_REGISTRY).every(([resource, config]) => {
const scopedRegistry = getPermissionsForScope(scope);
return Object.entries(scopedRegistry).every(([resource, config]) => {
if (resource === "*") return true;
return Object.keys(config)
.filter((action) => !action.startsWith("_"))
@@ -85,7 +90,8 @@ export function usePermissions(): UsePermissionsReturn {
} else {
// Filter out current resource permissions
newPermissions = newPermissions.filter((p) => !p.startsWith(`${resource}.`));
const resourceConfig = PERMISSION_REGISTRY[resource as keyof typeof PERMISSION_REGISTRY];
const scopedRegistry = getPermissionsForScope(scope);
const resourceConfig = scopedRegistry[resource as keyof typeof scopedRegistry];
if (!resourceConfig) return currentPermissions;
@@ -1,13 +1,21 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamMembersView } from "~/teams/team-members-view";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
@@ -54,6 +62,12 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const teamId = parseInt(id);
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id) {
return redirect("/auth/login");
}
const teamCaller = await createRouterCaller(viewerTeamsRouter);
const team = await teamCaller.get({ teamId });
@@ -70,6 +84,54 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
getCachedTeamAttributes(organizationId),
]);
const fallbackRolesCanListMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
// If the team is not private we allow members to list other members
if (!team.isPrivate) {
fallbackRolesCanListMembers.push(MembershipRole.MEMBER);
}
// Get specific PBAC permissions for team member actions
const permissions = await getSpecificPermissions({
userId: session.user.id,
teamId: teamId,
resource: Resource.Team,
userRole: team.membership.role,
actions: [
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.ListMembers,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ListMembers]: {
roles: fallbackRolesCanListMembers,
},
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
canImpersonate: permissions[CustomAction.Impersonate],
};
const facetedTeamValues = {
roles,
teams: [team],
@@ -84,7 +146,12 @@ const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
return (
<SettingsHeader title={t("team_members")} description={t("members_team_description")}>
<TeamMembersView team={team} facetedTeamValues={facetedTeamValues} attributes={attributes} />
<TeamMembersView
team={team}
facetedTeamValues={facetedTeamValues}
attributes={attributes}
permissions={memberPermissions}
/>
</SettingsHeader>
);
};
@@ -1,12 +1,20 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { MembersView } from "~/members/members-view";
export const generateMetadata = async () =>
@@ -38,10 +46,63 @@ const getCachedRoles = unstable_cache(
);
const Page = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
const [attributes, roles] = await Promise.all([getCachedAttributes(org.id), getCachedRoles(org.id)]);
const fallbackRolesThatCanSeeMembers: MembershipRole[] = [MembershipRole.ADMIN, MembershipRole.OWNER];
if (!org?.isPrivate) {
fallbackRolesThatCanSeeMembers.push(MembershipRole.MEMBER);
}
// Get specific PBAC permissions for organization member actions
const permissions = await getSpecificPermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
actions: [
CustomAction.ListMembers,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.Invite]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
// Map specific permissions to member actions
const memberPermissions = {
canListMembers: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
canImpersonate: permissions[CustomAction.Impersonate],
};
const facetedTeamValues = {
roles,
teams,
@@ -55,7 +116,13 @@ const Page = async () => {
};
return (
<MembersView org={org} teams={teams} facetedTeamValues={facetedTeamValues} attributes={attributes} />
<MembersView
org={org}
teams={teams}
facetedTeamValues={facetedTeamValues}
attributes={attributes}
permissions={memberPermissions}
/>
);
};
+8 -3
View File
@@ -4,17 +4,22 @@ import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
import type { UserListTableProps } from "@calcom/features/users/components/UserTable/UserListTable";
import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export const MembersView = (props: UserListTableProps) => {
export const MembersView = (props: UserListTableProps & { permissions?: MemberPermissions }) => {
const { t } = useLocale();
const { permissions, ...tableProps } = props;
// Use PBAC permissions if available, otherwise fall back to role-based check
const isOrgAdminOrOwner = props.org && checkAdminOrOwner(props.org.user.role);
const canLoggedInUserSeeMembers =
(props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate;
permissions?.canListMembers ??
((props.org?.isPrivate && isOrgAdminOrOwner) || isOrgAdminOrOwner || !props.org?.isPrivate);
return (
<LicenseRequired>
<div>{canLoggedInUserSeeMembers && <UserListTable {...props} />}</div>
<div>{canLoggedInUserSeeMembers && <UserListTable {...tableProps} permissions={permissions} />}</div>
{!canLoggedInUserSeeMembers && (
<div className="border-subtle rounded-xl border p-6" data-testid="members-privacy-warning">
<h2 className="text-default">{t("only_admin_can_see_members_of_org")}</h2>
+6 -2
View File
@@ -6,6 +6,7 @@ import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { MemberInvitationModalWithoutMembers } from "@calcom/features/ee/teams/components/MemberInvitationModal";
import MemberList from "@calcom/features/ee/teams/components/MemberList";
import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -23,15 +24,17 @@ interface TeamMembersViewProps {
}[];
};
attributes?: any[];
permissions: MemberPermissions;
}
export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProps) => {
export const TeamMembersView = ({ team, facetedTeamValues, permissions }: TeamMembersViewProps) => {
const { t } = useLocale();
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(false);
const [showInviteLinkSettingsModal, setShowInviteLinkSettingsModal] = useState(false);
// Use PBAC permissions if available, otherwise fall back to role-based check
const isTeamAdminOrOwner = checkAdminOrOwner(team.membership.role);
const canLoggedInUserSeeMembers = !team.isPrivate || isTeamAdminOrOwner;
const canLoggedInUserSeeMembers = permissions?.canListMembers ?? (!team.isPrivate || isTeamAdminOrOwner);
return (
<LicenseRequired>
@@ -43,6 +46,7 @@ export const TeamMembersView = ({ team, facetedTeamValues }: TeamMembersViewProp
isOrgAdminOrOwner={false}
setShowMemberInvitationModal={setShowMemberInvitationModal}
facetedTeamValues={facetedTeamValues}
permissions={permissions}
/>
</div>
)}
+7 -2
View File
@@ -1004,8 +1004,13 @@ export async function apiLogin(
*/
await page.goto(navigateToUrl || "/settings/my-account/profile");
// Wait for the session to be fully established
await page.waitForLoadState();
// Wait for the session API call to complete to ensure session is fully established
// Only wait if we're on a protected page that would trigger the session API call
try {
await page.waitForResponse("/api/auth/session", { timeout: 2000 });
} catch (error) {
// Session API call not made (likely on a public page), continue anyway
}
return response;
}
@@ -3403,6 +3403,7 @@
"pbac_action_read_team_bookings": "View Team Bookings",
"pbac_action_read_org_bookings": "View Organization Bookings",
"pbac_action_read_recordings": "View Recordings",
"pbac_action_impersonate": "Impersonate",
"role_created_successfully": "Role created successfully",
"role_updated_successfully": "Role updated successfully",
"delete_role": "Delete role",
@@ -3437,7 +3438,8 @@
"pbac_desc_invite_team_members": "Invite team members",
"pbac_desc_remove_team_members": "Remove team members",
"pbac_desc_change_team_member_role": "Change role of team members",
"pbac_desc_manage_teams": "All actions on teams",
"pbac_desc_impersonate_team_members": "Impersonate team members",
"pbac_desc_manage_teams": "All actions on teams across organization teams",
"pbac_desc_create_organization": "Create organization",
"pbac_desc_view_organization_details": "View organization details",
"pbac_desc_list_organization_members": "List organization members",
@@ -3445,6 +3447,7 @@
"pbac_desc_remove_organization_members": "Remove organization members",
"pbac_desc_manage_organization_billing": "Manage organization billing",
"pbac_desc_change_organization_member_role": "Change role of organization members",
"pbac_desc_impersonate_organization_members": "Impersonate organization members",
"pbac_desc_edit_organization_settings": "Edit organization settings",
"pbac_desc_manage_organizations": "All actions on organizations",
"pbac_desc_view_bookings": "View bookings",
@@ -2,12 +2,14 @@ import type { Session } from "next-auth";
import { describe, expect, it } from "vitest";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
import {
parseTeamId,
checkSelfImpersonation,
checkUserIdentifier,
checkGlobalPermission,
checkPBACImpersonationPermission,
} from "./ImpersonationProvider";
const session: Session = {
@@ -79,3 +81,41 @@ describe("checkPermission", () => {
expect(() => checkGlobalPermission(session)).not.toThrow();
});
});
describe("checkPBACImpersonationPermission", () => {
it("should return true for admin users", async () => {
const result = await checkPBACImpersonationPermission({
userId: 123,
teamId: 456,
userRole: MembershipRole.ADMIN,
});
expect(result).toBe(true);
});
it("should return true for owner users", async () => {
const result = await checkPBACImpersonationPermission({
userId: 123,
teamId: 456,
userRole: MembershipRole.OWNER,
});
expect(result).toBe(true);
});
it("should return false for member users", async () => {
const result = await checkPBACImpersonationPermission({
userId: 123,
teamId: 456,
userRole: MembershipRole.MEMBER,
});
expect(result).toBe(false);
});
it("should handle organization context", async () => {
const result = await checkPBACImpersonationPermission({
userId: 123,
organizationId: 789,
userRole: MembershipRole.ADMIN,
});
expect(result).toBe(true);
});
});
@@ -5,12 +5,16 @@ import { z } from "zod";
import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensureOrganizationIsReviewed";
import { getSession } from "@calcom/features/auth/lib/getSession";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import type { OrgProfile, PersonalProfile, UserAsPersonalProfile } from "@calcom/types/UserProfile";
import { Resource, CustomAction } from "../../../pbac/domain/types/permission-registry";
const teamIdschema = z.object({
teamId: z.preprocess((a) => parseInt(z.string().parse(a), 10), z.number().positive()),
});
@@ -113,6 +117,69 @@ export function checkGlobalPermission(session: Session | null) {
}
}
/**
* Check PBAC permissions for impersonation
* This function integrates with the new PBAC system to determine impersonation permissions
*/
export async function checkPBACImpersonationPermission({
userId,
teamId,
userRole,
organizationId,
}: {
userId: number;
teamId?: number;
userRole: MembershipRole;
organizationId?: number | null;
}): Promise<boolean> {
try {
// For organization-level impersonation
if (organizationId) {
const orgPermissions = await getSpecificPermissions({
userId,
teamId: organizationId,
resource: Resource.Organization,
userRole,
actions: [CustomAction.Impersonate],
fallbackRoles: {
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (orgPermissions[CustomAction.Impersonate]) {
return true;
}
}
// For team-level impersonation
if (teamId) {
const teamPermissions = await getSpecificPermissions({
userId,
teamId,
resource: Resource.Team,
userRole,
actions: [CustomAction.Impersonate],
fallbackRoles: {
[CustomAction.Impersonate]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return teamPermissions[CustomAction.Impersonate] ?? false;
}
// Fallback to role-based check if no team/org context
return userRole === MembershipRole.ADMIN || userRole === MembershipRole.OWNER;
} catch (error) {
console.error("Error checking PBAC impersonation permission:", error);
// Fallback to role-based check on error
return userRole === MembershipRole.ADMIN || userRole === MembershipRole.OWNER;
}
}
async function getImpersonatedUser({
session,
teamId,
@@ -295,11 +362,6 @@ const ImpersonationProvider = CredentialsProvider({
teams: {
where: {
AND: [
{
role: {
in: ["ADMIN", "OWNER"],
},
},
{
team: {
id: teamId,
@@ -318,6 +380,19 @@ const ImpersonationProvider = CredentialsProvider({
throw new Error("Error-UserHasNoTeams: You do not have permission to do this.");
}
// Check PBAC permissions for impersonation
const hasImpersonationPermission = await checkPBACImpersonationPermission({
userId: session?.user.id as number,
teamId,
userRole: sessionUserFromDb?.teams[0].role as MembershipRole,
organizationId: session?.user.org?.id,
});
if (!hasImpersonationPermission) {
throw new Error("You do not have permission to impersonate this user.");
}
// Legacy role check as additional safeguard (PBAC should handle this but keeping for backwards compatibility)
// We find team by ID so we know there is only one team in the array
if (sessionUserFromDb?.teams[0].role === "ADMIN" && impersonatedUser.teams[0].role === "OWNER") {
throw new Error("You do not have permission to do this.");
@@ -15,7 +15,6 @@ import { useQueryState, parseAsBoolean } from "nuqs";
import { useMemo, useReducer, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import {
DataTableProvider,
@@ -30,10 +29,10 @@ import {
} from "@calcom/features/data-table";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink";
import type { MemberPermissions } from "@calcom/features/users/components/UserTable/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
@@ -163,6 +162,7 @@ interface Props {
}[];
}[];
};
permissions: MemberPermissions;
}
export default function MemberList(props: Props) {
@@ -292,8 +292,6 @@ function MemberListContent(props: Props) {
// return owners.length;
// };
const isAdminOrOwner = checkAdminOrOwner(props.team.membership.role);
const removeMember = () =>
removeMemberMutation.mutate({
teamIds: [props.team?.id],
@@ -429,17 +427,19 @@ function MemberListContent(props: Props) {
cell: ({ row }) => {
const user = row.original;
const isSelf = user.id === session?.user.id;
// TODO(SEAN) In a follow up can we rename canChangeMemberRole to canEditMembers - role is a bit specific.
const canChangeRole = props.permissions?.canChangeMemberRole ?? false;
const canRemove = props.permissions?.canRemove ?? false;
const canImpersonate = props.permissions?.canImpersonate ?? false;
const canResendInvitation = props.permissions?.canInvite ?? false;
const editMode =
(props.team.membership?.role === MembershipRole.OWNER &&
(user.role !== MembershipRole.OWNER || !isSelf)) ||
(props.team.membership?.role === MembershipRole.ADMIN && user.role !== MembershipRole.OWNER) ||
props.isOrgAdminOrOwner;
[canChangeRole, canRemove, canImpersonate, canResendInvitation].some(Boolean) && !isSelf;
const impersonationMode =
editMode &&
canImpersonate &&
!user.disableImpersonation &&
user.accepted &&
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "true";
const resendInvitation = editMode && !user.accepted;
return (
<>
{props.team.membership?.accepted && (
@@ -495,22 +495,24 @@ function MemberListContent(props: Props) {
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
{canChangeRole ? (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
) : null}
{impersonationMode && (
<>
<DropdownMenuItem>
@@ -532,7 +534,7 @@ function MemberListContent(props: Props) {
<DropdownMenuSeparator />
</>
)}
{resendInvitation && (
{canResendInvitation && (
<DropdownMenuItem>
<DropdownItem
type="button"
@@ -548,23 +550,25 @@ function MemberListContent(props: Props) {
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_DELETE_ID",
payload: {
user,
showModal: true,
},
})
}
color="destructive"
StartIcon="user-x">
{t("remove")}
</DropdownItem>
</DropdownMenuItem>
{canRemove ? (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_DELETE_ID",
payload: {
user,
showModal: true,
},
})
}
color="destructive"
StartIcon="user-x">
{t("remove")}
</DropdownItem>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
@@ -713,7 +717,7 @@ function MemberListContent(props: Props) {
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
{isAdminOrOwner && (
{props.permissions.canInvite && (
<DataTableToolbar.CTA
type="button"
color="primary"
@@ -28,6 +28,7 @@ export enum CustomAction {
ReadTeamBookings = "readTeamBookings",
ReadOrgBookings = "readOrgBookings",
ReadRecordings = "readRecordings",
Impersonate = "impersonate",
}
export enum Scope {
@@ -263,21 +264,34 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
category: "team",
i18nKey: "pbac_action_invite",
descriptionI18nKey: "pbac_desc_invite_team_members",
dependsOn: ["team.read"],
dependsOn: ["team.read", "team.listMembers"],
},
[CustomAction.Remove]: {
description: "Remove team members",
category: "team",
i18nKey: "pbac_action_remove",
descriptionI18nKey: "pbac_desc_remove_team_members",
dependsOn: ["team.read"],
dependsOn: ["team.read", "team.listMembers"],
},
[CustomAction.ListMembers]: {
description: "List team members",
category: "team",
i18nKey: "pbac_action_list_members",
descriptionI18nKey: "pbac_desc_list_team_members",
},
[CustomAction.ChangeMemberRole]: {
description: "Change role of team members",
category: "team",
i18nKey: "pbac_action_change_member_role",
descriptionI18nKey: "pbac_desc_change_team_member_role",
dependsOn: ["team.read"],
dependsOn: ["team.read", "team.listMembers"],
},
[CustomAction.Impersonate]: {
description: "Impersonate team members",
category: "team",
i18nKey: "pbac_action_impersonate",
descriptionI18nKey: "pbac_desc_impersonate_team_members",
dependsOn: ["team.read", "team.listMembers"],
},
},
[Resource.Organization]: {
@@ -338,6 +352,13 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
scope: [Scope.Organization],
dependsOn: ["organization.listMembers", "role.read"],
},
[CustomAction.Impersonate]: {
description: "Impersonate organization members",
category: "org",
i18nKey: "pbac_action_impersonate",
descriptionI18nKey: "pbac_desc_impersonate_organization_members",
scope: [Scope.Organization],
},
[CrudAction.Update]: {
description: "Edit organization settings",
category: "org",
@@ -4,7 +4,7 @@ import type { MembershipRole } from "@calcom/prisma/enums";
import { PermissionMapper } from "../domain/mappers/PermissionMapper";
import type { Resource } from "../domain/types/permission-registry";
import { CrudAction } from "../domain/types/permission-registry";
import { CrudAction, CustomAction } from "../domain/types/permission-registry";
import { PermissionCheckService } from "../services/permission-check.service";
interface RoleMapping {
@@ -85,3 +85,58 @@ export const getResourcePermissions = async ({
canCreate: roleActions[CrudAction.Create] ?? false,
};
};
// Enhanced function to get specific custom action permissions
type ActionType = CrudAction | CustomAction;
interface SpecificActionMapping {
[key: string]: RoleMapping;
}
interface SpecificPermissionsOptions {
userId: number;
teamId: number;
resource: Resource;
userRole: MembershipRole;
actions: ActionType[];
fallbackRoles?: SpecificActionMapping;
}
export const getSpecificPermissions = async ({
userId,
teamId,
resource,
userRole,
actions,
fallbackRoles = {},
}: SpecificPermissionsOptions): Promise<Record<string, boolean>> => {
const featureRepo = new FeaturesRepository(prisma);
const permissionService = new PermissionCheckService();
const pbacEnabled = await featureRepo.checkIfTeamHasFeature(teamId, "pbac");
// If PBAC is disabled, use fallback role configuration
if (!pbacEnabled) {
const permissions: Record<string, boolean> = {};
for (const action of actions) {
permissions[action] = checkRoleAccess(userRole, fallbackRoles[action]);
}
return permissions;
}
// PBAC is enabled, get permissions from the service
const resourcePermissions = await permissionService.getResourcePermissions({
userId,
teamId,
resource,
});
const roleActions = PermissionMapper.toActionMap(resourcePermissions, resource);
const permissions: Record<string, boolean> = {};
for (const action of actions) {
permissions[action] = roleActions[action] ?? false;
}
return permissions;
};
@@ -50,7 +50,7 @@ import { EditUserSheet } from "./EditSheet/EditUserSheet";
import { ImpersonationMemberModal } from "./ImpersonationMemberModal";
import { InviteMemberModal } from "./InviteMemberModal";
import { TableActions } from "./UserTableActions";
import type { UserTableState, UserTableAction, UserTableUser } from "./types";
import type { UserTableState, UserTableAction, UserTableUser, MemberPermissions } from "./types";
const initialState: UserTableState = {
changeMemberRole: {
@@ -122,6 +122,7 @@ export type UserListTableProps = {
}[];
}[];
};
permissions?: MemberPermissions;
};
export function UserListTable(props: UserListTableProps) {
@@ -132,7 +133,13 @@ export function UserListTable(props: UserListTableProps) {
);
}
function UserListTableContent({ org, attributes, teams, facetedTeamValues }: UserListTableProps) {
function UserListTableContent({
org,
attributes,
teams,
facetedTeamValues,
permissions,
}: UserListTableProps) {
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
const orgBranding = useOrgBranding();
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
@@ -169,11 +176,12 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
const flatData = useMemo<UserTableUser[]>(() => data?.rows ?? [], [data]);
const memorisedColumns = useMemo(() => {
const permissions = {
canEdit: adminOrOwner,
canRemove: adminOrOwner,
canResendInvitation: adminOrOwner,
canImpersonate: false,
// Use PBAC permissions if available, otherwise fall back to role-based check
const tablePermissions = {
canEdit: permissions?.canChangeMemberRole ?? adminOrOwner,
canRemove: permissions?.canRemove ?? adminOrOwner,
canResendInvitation: permissions?.canInvite ?? adminOrOwner,
canImpersonate: permissions?.canImpersonate ?? adminOrOwner,
};
const generateAttributeColumns = () => {
if (!attributes?.length) {
@@ -445,14 +453,18 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
size: 80,
cell: ({ row }) => {
const user = row.original;
const permissionsRaw = permissions;
const permissionsRaw = tablePermissions;
const isSelf = user.id === session?.user.id;
const permissionsForUser = {
canEdit: permissionsRaw.canEdit && user.accepted && !isSelf,
canRemove: permissionsRaw.canRemove && !isSelf,
canImpersonate:
user.accepted && !user.disableImpersonation && !isSelf && !!org?.canAdminImpersonate,
user.accepted &&
!user.disableImpersonation &&
!isSelf &&
!!org?.canAdminImpersonate &&
permissionsRaw.canImpersonate,
canLeave: user.accepted && isSelf,
canResendInvitation: permissionsRaw.canResendInvitation && !user.accepted,
};
@@ -470,7 +482,7 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
];
return cols;
}, [session?.user.id, adminOrOwner, dispatch, domain, attributes, org?.canAdminImpersonate]);
}, [session?.user.id, adminOrOwner, dispatch, domain, attributes, org?.canAdminImpersonate, permissions]);
const table = useReactTable({
data: flatData,
@@ -619,7 +631,7 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
</p>
{!isPlatformUser ? (
<>
{adminOrOwner && <TeamListBulkAction table={table} />}
{permissions?.canChangeMemberRole && <TeamListBulkAction table={table} />}
{numberOfSelectedRows >= 2 && (
<DataTableSelectionBar.Button
color="secondary"
@@ -628,11 +640,15 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
{t("group_meeting")}
</DataTableSelectionBar.Button>
)}
{adminOrOwner && <MassAssignAttributesBulkAction table={table} filters={columnFilters} />}
{adminOrOwner && <EventTypesList table={table} orgTeams={teams} />}
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
)}
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
<EventTypesList table={table} orgTeams={teams} />
)}
</>
) : null}
{adminOrOwner && (
{(permissions?.canRemove ?? adminOrOwner) && (
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
@@ -660,7 +676,7 @@ function UserListTableContent({ org, attributes, teams, facetedTeamValues }: Use
data-testid="export-members-button">
{t("download")}
</DataTableToolbar.CTA>
{adminOrOwner && (
{(permissions?.canInvite ?? adminOrOwner) && (
<DataTableToolbar.CTA
type="button"
color="primary"
@@ -52,3 +52,11 @@ export type PlatformManagedUserTableAction =
| {
type: "CLOSE_MODAL";
};
export interface MemberPermissions {
canListMembers: boolean;
canInvite: boolean;
canChangeMemberRole: boolean;
canRemove: boolean;
canImpersonate: boolean;
}
@@ -0,0 +1,9 @@
-- Add team.listMembers permission to admin role
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'admin_role', resource, action, NOW()
FROM (
VALUES
('team', 'listMembers')
) AS permissions(resource, action)
ON CONFLICT ("roleId", resource, action) DO NOTHING;
@@ -0,0 +1,18 @@
-- Add impersonate permissions to admin role
-- These permissions allow administrators to impersonate team and organization members
-- Insert impersonate permissions for admin role
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'admin_role', resource, action, NOW()
FROM (
VALUES
-- Team impersonate permission
('team', 'impersonate'),
-- Organization impersonate permission
('organization', 'impersonate')
) AS permissions(resource, action)
ON CONFLICT ("roleId", resource, action) DO NOTHING;
-- Note: Owner role already has wildcard permissions (*.*) so it inherits all impersonate permissions automatically
@@ -0,0 +1,7 @@
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'member_role', resource, action, NOW()
FROM (
VALUES
('team', 'listMembers')
) AS permissions(resource, action);
@@ -1,7 +1,9 @@
import { TeamBilling } from "@calcom/ee/billing/teams";
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -21,9 +23,38 @@ export async function bulkDeleteUsersHandler({ ctx, input }: BulkDeleteUsersHand
if (!currentUserOrgId) throw new TRPCError({ code: "UNAUTHORIZED" });
// check if user is admin of organization
if (!(await isOrganisationAdmin(currentUser?.id, currentUserOrgId)))
// Get user's membership role in the organization
const membership = await prisma.membership.findFirst({
where: {
userId: currentUser.id,
teamId: currentUserOrgId,
},
select: {
role: true,
},
});
if (!membership) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not a member of this organization." });
}
// Check PBAC permissions for removing organization members
const permissions = await getSpecificPermissions({
userId: currentUser.id,
teamId: currentUserOrgId,
resource: Resource.Organization,
userRole: membership.role,
actions: [CustomAction.Remove],
fallbackRoles: {
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!permissions[CustomAction.Remove]) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// Loop over all users in input.userIds and remove all memberships for the organization including child teams
const deleteMany = prisma.membership.deleteMany({
@@ -1,5 +1,7 @@
import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -18,9 +20,42 @@ export async function getUserHandler({ input, ctx }: AdminVerifyOptions) {
if (!currentUser.organizationId) throw new TRPCError({ code: "UNAUTHORIZED" });
// check if user is admin of organization
if (!(await isOrganisationAdmin(currentUser?.id, currentUser.organizationId)))
// Get user's membership role in the organization
const currentUserMembership = await prisma.membership.findFirst({
where: {
userId: currentUser.id,
teamId: currentUser.organizationId,
},
select: {
role: true,
},
});
if (!currentUserMembership) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "User is not a member of this organization." });
}
// Check PBAC permissions for viewing/editing organization members
const permissions = await getSpecificPermissions({
userId: currentUser.id,
teamId: currentUser.organizationId,
resource: Resource.Organization,
userRole: currentUserMembership.role,
actions: [CustomAction.ListMembers, CustomAction.ChangeMemberRole],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.ChangeMemberRole]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
// User needs either ListMembers (to view) or ChangeMemberRole (to edit) permission
if (!permissions[CustomAction.ListMembers] || !permissions[CustomAction.ChangeMemberRole]) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// get requested user from database and ensure they are in the same organization
const [requestedUser, membership, teams] = await prisma.$transaction([
@@ -9,6 +9,7 @@ const prismaMock = {
membership: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
findFirst: vi.fn().mockResolvedValue({ role: "ADMIN" }),
},
attributeOption: {
findMany: vi.fn().mockResolvedValue([
@@ -16,11 +17,16 @@ const prismaMock = {
{ id: "2", value: "value2", isGroup: false },
]),
},
attributeToUser: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.spyOn(prisma.membership, "findMany").mockImplementation(prismaMock.membership.findMany);
vi.spyOn(prisma.membership, "count").mockImplementation(prismaMock.membership.count);
vi.spyOn(prisma.membership, "findFirst").mockImplementation(prismaMock.membership.findFirst);
vi.spyOn(prisma.attributeOption, "findMany").mockImplementation(prismaMock.attributeOption.findMany);
vi.spyOn(prisma.attributeToUser, "findMany").mockImplementation(prismaMock.attributeToUser.findMany);
// Mock FeaturesRepository
const mockCheckIfTeamHasFeature = vi.fn();
@@ -30,6 +36,20 @@ vi.mock("@calcom/features/flags/features.repository", () => ({
})),
}));
// Mock PBAC permissions
vi.mock("@calcom/features/pbac/lib/resource-permissions", () => ({
getSpecificPermissions: vi.fn().mockResolvedValue({
listMembers: true,
}),
}));
// Mock UserRepository
vi.mock("@calcom/lib/server/repository/user", () => ({
UserRepository: vi.fn().mockImplementation(() => ({
enrichUserWithItsProfile: vi.fn().mockImplementation(({ user }) => user),
})),
}));
const ORGANIZATION_ID = 123;
const mockUser = {
@@ -1,9 +1,12 @@
import { makeWhereClause } from "@calcom/features/data-table/lib/server";
import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data-table/lib/types";
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 { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -69,7 +72,38 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." });
}
if (ctx.user.organization.isPrivate && !ctx.user.organization.isOrgAdmin) {
// Get user's membership role in the organization
const membership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: organizationId,
},
select: {
role: true,
},
});
if (!membership) {
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({
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],
},
},
});
if (!permissions[CustomAction.ListMembers]) {
return {
canUserGetMembers: false,
rows: [],
@@ -78,7 +112,6 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
},
};
}
const { limit, offset } = input;
const roleFilter = filters.find((filter) => filter.id === "role") as
@@ -1,11 +1,13 @@
import type { Prisma } from "@prisma/client";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { Resource, CustomAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { getSpecificPermissions } from "@calcom/features/pbac/lib/resource-permissions";
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";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -146,26 +148,17 @@ export const listMembersHandler = async ({ ctx, input }: ListMembersHandlerOptio
};
const checkCanAccessMembers = async (ctx: ListMembersHandlerOptions["ctx"], teamId: number) => {
const isOrgPrivate = ctx.user.profile?.organization?.isPrivate;
const isOrgAdminOrOwner = ctx.user.organization?.isOrgAdmin;
const orgId = ctx.user.organizationId;
const isTargetingOrg = teamId === ctx.user.organizationId;
if (isTargetingOrg) {
return isOrgAdminOrOwner || !isOrgPrivate;
}
// Get team info to check if it's private
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
where: { id: teamId },
select: { isPrivate: true },
});
if (!team) return false;
if (isOrgAdminOrOwner && team?.parentId === orgId) {
return true;
}
// Get user's membership in the team
const membership = await prisma.membership.findFirst({
where: {
teamId,
@@ -176,12 +169,26 @@ const checkCanAccessMembers = async (ctx: ListMembersHandlerOptions["ctx"], team
if (!membership) return false;
const isTeamAdminOrOwner = checkAdminOrOwner(membership?.role);
// Determine the resource type based on whether this is an org or team
const resource = isTargetingOrg ? Resource.Organization : Resource.Team;
if (team?.isPrivate && !isTeamAdminOrOwner) {
return false;
}
return true;
// Check PBAC permissions for listing members
const permissions = await getSpecificPermissions({
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],
},
},
});
return permissions[CustomAction.ListMembers];
};
export default listMembersHandler;
@@ -1,6 +1,11 @@
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 { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { isTeamAdmin, isTeamOwner } from "@calcom/lib/server/queries/teams";
import { isTeamOwner } from "@calcom/lib/server/queries/teams";
import { TeamService } from "@calcom/lib/server/service/teamService";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
@@ -22,42 +27,93 @@ export const removeMemberHandler = async ({ ctx, input }: RemoveMemberOptions) =
const { memberIds, teamIds, isOrg } = input;
const isAdmin = await Promise.all(
teamIds.map(async (teamId) => await isTeamAdmin(ctx.user.id, teamId))
// Check PBAC permissions for each team
const hasRemovePermission = await Promise.all(
teamIds.map(async (teamId) => {
// Get user's membership role in this team
const membership = await prisma.membership.findFirst({
where: {
userId: ctx.user.id,
teamId: teamId,
},
select: {
role: true,
},
});
if (!membership) return false;
// Check PBAC permissions for removing team members
const permissions = await getSpecificPermissions({
userId: ctx.user.id,
teamId: teamId,
resource: isOrg ? Resource.Organization : Resource.Team,
userRole: membership.role,
actions: [CustomAction.Remove],
fallbackRoles: {
[CustomAction.Remove]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
return permissions[CustomAction.Remove];
})
).then((results) => results.every((result) => result));
const isOrgAdmin = ctx.user.profile?.organizationId
? await isTeamAdmin(ctx.user.id, ctx.user.profile?.organizationId)
: false;
// Check if user is trying to remove themselves (allowed for non-owners)
const isRemovingSelf = memberIds.length === 1 && memberIds[0] === ctx.user.id;
if (!(isAdmin || isOrgAdmin) && memberIds.every((memberId) => ctx.user.id !== memberId))
// Allow if user has remove permission OR if they're removing themselves
if (!hasRemovePermission && !isRemovingSelf) {
throw new TRPCError({ code: "UNAUTHORIZED" });
// Only a team owner can remove another team owner.
const isAnyMemberOwnerAndCurrentUserNotOwner = await Promise.all(
memberIds.map(async (memberId) => {
const isAnyTeamOwnerAndCurrentUserNotOwner = await Promise.all(
teamIds.map(async (teamId) => {
return (await isTeamOwner(memberId, teamId)) && !(await isTeamOwner(ctx.user.id, teamId));
})
).then((results) => results.some((result) => result));
return isAnyTeamOwnerAndCurrentUserNotOwner;
})
).then((results) => results.some((result) => result));
if (isAnyMemberOwnerAndCurrentUserNotOwner) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Only a team owner can remove another team owner.",
});
}
if (memberIds.some((memberId) => ctx.user.id === memberId) && isAdmin && !isOrgAdmin)
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",
});
// TODO(SEAN): Remove this after PBAC is rolled out.
// Check if any team has PBAC enabled
const featuresRepository = new FeaturesRepository(prisma);
const pbacEnabledForTeams = await Promise.all(
teamIds.map(async (teamId) => await featuresRepository.checkIfTeamHasFeature(teamId, "pbac"))
);
const isAnyTeamPBACEnabled = pbacEnabledForTeams.some((enabled) => enabled);
// Only apply traditional owner-based logic if PBAC is not enabled for any teams
if (!isAnyTeamPBACEnabled) {
// Only a team owner can remove another team owner.
const isAnyMemberOwnerAndCurrentUserNotOwner = await Promise.all(
memberIds.map(async (memberId) => {
const isAnyTeamOwnerAndCurrentUserNotOwner = await Promise.all(
teamIds.map(async (teamId) => {
return (await isTeamOwner(memberId, teamId)) && !(await isTeamOwner(ctx.user.id, teamId));
})
).then((results) => results.some((result) => result));
return isAnyTeamOwnerAndCurrentUserNotOwner;
})
).then((results) => results.some((result) => result));
if (isAnyMemberOwnerAndCurrentUserNotOwner) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Only a team owner can remove another team owner.",
});
}
// Check if user is trying to remove themselves from a team they own (prevent this)
if (isRemovingSelf && hasRemovePermission) {
// Additional check: ensure they're not an owner trying to remove themselves
const isOwnerOfAnyTeam = await Promise.all(
teamIds.map(async (teamId) => await isTeamOwner(ctx.user.id, teamId))
).then((results) => results.some((result) => result));
if (isOwnerOfAnyTeam) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can not remove yourself from a team you own.",
});
}
}
}
await TeamService.removeMembers({ teamIds, userIds: memberIds, isOrg });
};