feat: add permission for editUsers + implement UI (#25402)

## What does this PR do?
This PR implements attributes PBAC - router checks + UI

## Visual Demo (For contributors especially)

A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**.

#### Video Demo (if applicable):

- Show screen recordings of the issue or feature.
- Demonstrate how to reproduce the issue, the behavior before and after the change.

#### Image Demo (if applicable):

- Add side-by-side screenshots of the original and updated change.
- Highlight any significant change(s).

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?
Enable PBAC on an org 
Create a custom role -> advanced -> organizations -> "editUser" 
Assign it to a user
impersonate user
test they have access to all things attributes
remove permissions
check they dont have permissions. 

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings










<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds a new PBAC “editUsers” permission and read gating for Attributes, updating the UI and backend so users can view and edit attributes only when allowed.

- **New Features**
  - Added CustomAction.EditUsers to the permission registry for organization-scoped attribute editing.
  - Settings computes canViewAttributes and shows the Attributes tab only when allowed.
  - Members page fetches Attributes permissions and exposes canViewAttributes and canEditAttributesForUser to the UI.
  - User Edit Sheet hides attributes without read permission and shows attribute editing and the bulk “Mass Assign Attributes” action only with editUsers; other user edits depend on changeMemberRole.
  - Attributes TRPC router gates create/edit/delete/toggle via PBAC and requires organization.attributes.editUsers for assign/bulk-assign; added a helper to create PBAC-aware procedures.

- **Migration**
  - Run the new Prisma migration to seed the admin role with editUsers.
  - If using custom roles, grant Edit Users under Attributes as needed.

<sup>Written for commit 856aa2e8e1521fc22cd71cb0fa6d720036efe8a2. Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
sean-brydon
2025-11-26 13:58:29 +00:00
committed by GitHub
parent 7d2a06ddb6
commit 2a7c6590a3
13 changed files with 266 additions and 99 deletions
@@ -181,6 +181,7 @@ export interface SettingsPermissions {
canViewRoles?: boolean;
canViewOrganizationBilling?: boolean;
canUpdateOrganization?: boolean;
canViewAttributes?: boolean;
}
const useTabs = ({
@@ -211,7 +212,7 @@ const useTabs = ({
(child) => permissions?.canUpdateOrganization || !organizationAdminKeys.includes(child.name)
);
if (permissions?.canUpdateOrganization) {
if (permissions?.canViewAttributes) {
newArray.splice(4, 0, {
name: "attributes",
href: "/settings/organizations/attributes",
@@ -452,7 +453,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("profile")}
href={`/settings/teams/${team.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto px-2!"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
)}
@@ -460,7 +461,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("members")}
href={`/settings/teams/${team.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto px-2!"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
{/* Show roles only for sub-teams with PBAC-enabled parent */}
@@ -481,7 +482,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto px-2!"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
{/* Hide if there is a parent ID */}
@@ -491,7 +492,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto px-2!"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
</>
@@ -500,7 +501,7 @@ const TeamListCollapsible = ({ teamFeatures }: { teamFeatures?: Record<number, T
name={t("settings")}
href={`/settings/teams/${team.id}/settings`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto px-2!"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
</>
@@ -578,7 +579,7 @@ const SettingsSidebarContainer = ({
<nav
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
className={classNames(
"no-scrollbar bg-cal-muted fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col stack-y-1 overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
"no-scrollbar bg-cal-muted stack-y-1 fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
className,
navigationIsOpenedOnMobile
? "translate-x-0 opacity-100"
@@ -626,7 +627,7 @@ const SettingsSidebarContainer = ({
isExternalLink={child.isExternalLink}
href={child.href || "/"}
textClassNames="text-emphasis font-medium text-sm"
className={`h-7 w-fit px-2! ${
className={`px-2! h-7 w-fit ${
tab.children && index === tab.children?.length - 1 && "mb-3!"
}`}
disableChevron
@@ -668,7 +669,7 @@ const SettingsSidebarContainer = ({
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto px-2!"
className="px-2! me-5 h-7 w-auto"
icon="plus"
disableChevron
/>
@@ -772,14 +773,14 @@ const SettingsSidebarContainer = ({
name={t("profile")}
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto px-2!"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
<VerticalTabItem
name={t("members")}
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
className="me-5 h-7 w-auto px-2!"
className="px-2! me-5 h-7 w-auto"
disableChevron
/>
<>
@@ -881,7 +882,7 @@ export default function SettingsLayoutAppDirClient({
TopNavContainer={
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
}>
<div className="flex flex-1 *:flex-1">
<div className="*:flex-1 flex flex-1">
<div
className={classNames("mx-auto max-w-full justify-center lg:max-w-3xl", rest.containerClassName)}>
<ErrorBoundary>{children}</ErrorBoundary>
@@ -48,6 +48,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
let canViewRoles = false;
let canViewOrganizationBilling = false;
let canUpdateOrganization = false;
let canViewAttributes = false;
const orgId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
// For now we only grab organization features but it would be nice to fetch these on the server side for specific team feature flags
@@ -66,9 +67,10 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
if (isPbacEnabled) {
// Only fetch and apply PBAC permissions if the feature is enabled
const [rolePermissions, organizationPermissions] = await Promise.all([
const [rolePermissions, organizationPermissions, attributesPermissions] = await Promise.all([
getCachedResourcePermissions(userId, orgId, Resource.Role),
getCachedResourcePermissions(userId, orgId, Resource.Organization),
getCachedResourcePermissions(userId, orgId, Resource.Attributes),
]);
// Check if user has permission to read roles
@@ -77,11 +79,14 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
const orgActions = PermissionMapper.toActionMap(organizationPermissions, Resource.Organization);
canViewOrganizationBilling = orgActions[CustomAction.ManageBilling] ?? isOrgAdminOrOwner;
canUpdateOrganization = orgActions[CrudAction.Update] ?? isOrgAdminOrOwner;
const attributesActions = PermissionMapper.toActionMap(attributesPermissions, Resource.Attributes);
canViewAttributes = attributesActions[CrudAction.Read] ?? isOrgAdminOrOwner;
} else {
// Fall back to legacy permissions when PBAC is not enabled or features not loaded
canViewRoles = features ? isOrgAdminOrOwner : false;
canViewOrganizationBilling = isOrgAdminOrOwner;
canUpdateOrganization = isOrgAdminOrOwner;
canViewAttributes = isOrgAdminOrOwner;
}
}
@@ -90,7 +95,7 @@ export default async function SettingsLayoutAppDir(props: SettingsLayoutProps) {
<SettingsLayoutAppDirClient
{...props}
teamFeatures={teamFeatures ?? {}}
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization }}
permissions={{ canViewRoles, canViewOrganizationBilling, canUpdateOrganization, canViewAttributes }}
/>
</>
);
@@ -5,7 +5,7 @@ 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 { Resource, CustomAction, CrudAction } 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";
@@ -63,50 +63,69 @@ const Page = async () => {
}
// 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.ListMembersPrivate,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
const [orgPermissions, attributesPermissions] = await Promise.all([
getSpecificPermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
actions: [
CustomAction.ListMembers,
CustomAction.ListMembersPrivate,
CustomAction.Invite,
CustomAction.ChangeMemberRole,
CustomAction.Remove,
CustomAction.Impersonate,
],
fallbackRoles: {
[CustomAction.ListMembers]: {
roles: fallbackRolesThatCanSeeMembers,
},
[CustomAction.ListMembersPrivate]: {
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],
},
},
[CustomAction.ListMembersPrivate]: {
roles: fallbackRolesThatCanSeeMembers,
}),
getSpecificPermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Attributes,
userRole: session.user.org.role,
actions: [CrudAction.Read, CustomAction.EditUsers],
fallbackRoles: {
[CrudAction.Read]: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
[CustomAction.EditUsers]: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
[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: org.isPrivate
? permissions[CustomAction.ListMembersPrivate]
: permissions[CustomAction.ListMembers],
canInvite: permissions[CustomAction.Invite],
canChangeMemberRole: permissions[CustomAction.ChangeMemberRole],
canRemove: permissions[CustomAction.Remove],
canImpersonate: permissions[CustomAction.Impersonate],
? orgPermissions[CustomAction.ListMembersPrivate]
: orgPermissions[CustomAction.ListMembers],
canInvite: orgPermissions[CustomAction.Invite],
canChangeMemberRole: orgPermissions[CustomAction.ChangeMemberRole],
canRemove: orgPermissions[CustomAction.Remove],
canImpersonate: orgPermissions[CustomAction.Impersonate],
canViewAttributes: attributesPermissions[CrudAction.Read],
canEditAttributesForUser: attributesPermissions[CustomAction.EditUsers],
};
const facetedTeamValues = {
@@ -3599,6 +3599,7 @@
"pbac_action_read_org_bookings": "View Organization Bookings",
"pbac_action_read_recordings": "View Recordings",
"pbac_action_impersonate": "Impersonate",
"pbac_action_edit_users": "Edit Users",
"role_created_successfully": "Role created successfully",
"role_updated_successfully": "Role updated successfully",
"delete_role": "Delete role",
@@ -3612,6 +3613,7 @@
"pbac_desc_update_organization_attributes": "Update organization attributes",
"pbac_desc_delete_organization_attributes": "Delete organization attributes",
"pbac_desc_create_organization_attributes": "Create organization attributes",
"pbac_desc_edit_user_attributes": "Edit user attributes",
"pbac_desc_view_roles": "View roles",
"pbac_desc_update_roles": "Update roles",
"pbac_desc_delete_roles": "Delete roles",
@@ -34,6 +34,7 @@ export enum CustomAction {
ReadOrgBookings = "readOrgBookings",
ReadRecordings = "readRecordings",
Impersonate = "impersonate",
EditUsers = "editUsers",
}
export enum Scope {
@@ -316,6 +317,7 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
category: "team",
i18nKey: "pbac_action_list_members",
descriptionI18nKey: "pbac_desc_list_team_members",
dependsOn: ["team.read"],
visibleWhen: {
teamPrivacy: "public", // Only show for public teams
},
@@ -559,6 +561,22 @@ export const PERMISSION_REGISTRY: PermissionRegistry = {
scope: [Scope.Organization],
dependsOn: ["organization.attributes.read"],
},
[CustomAction.EditUsers]: {
description: "Edit user attributes",
category: "attributes",
i18nKey: "pbac_action_edit_users",
descriptionI18nKey: "pbac_desc_edit_user_attributes",
scope: [Scope.Organization],
dependsOn: [
"organization.read",
"organization.listMembers",
"organization.attributes.read",
"organization.attributes.update",
"organization.attributes.delete",
"organization.attributes.create",
"organization.changeMemberRole",
],
},
},
[Resource.RoutingForm]: {
_resource: {
@@ -71,11 +71,13 @@ export function EditForm({
avatarUrl,
domainUrl,
dispatch,
canEditAttributesForUser,
}: {
selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"];
avatarUrl: string;
domainUrl: string;
dispatch: Dispatch<UserTableAction>;
canEditAttributesForUser?: boolean;
}) {
const setEditMode = useEditMode((state) => state.setEditMode);
const [mutationLoading, setMutationLoading] = useState(false);
@@ -249,8 +251,12 @@ export function EditForm({
<Label>{t("timezone")}</Label>
<TimezoneSelect value={watchTimezone ?? "America/Los_Angeles"} />
</div>
<Divider />
<AttributesList selectedUserId={selectedUser?.id} />
{canEditAttributesForUser && (
<>
<Divider />
<AttributesList selectedUserId={selectedUser?.id} />
</>
)}
</SheetBody>
<SheetFooter>
<Button
@@ -23,9 +23,15 @@ function removeProtocol(url: string) {
export function EditUserSheet({
state,
dispatch,
canViewAttributes,
canEditAttributesForUser,
canChangeMemberRole,
}: {
state: UserTableState;
dispatch: Dispatch<UserTableAction>;
canViewAttributes?: boolean;
canEditAttributesForUser?: boolean;
canChangeMemberRole?: boolean;
}) {
const { t } = useLocale();
const { user: selectedUser } = state.editSheet;
@@ -47,7 +53,7 @@ export function EditUserSheet({
userId: selectedUser?.id,
},
{
enabled: !!selectedUser?.id,
enabled: !!selectedUser?.id && !!canViewAttributes,
}
);
@@ -83,8 +89,8 @@ export function EditUserSheet({
</p>
</div>
</SheetHeader>
<SheetBody className="flex flex-col stack-y-4 p-4">
<div className="mb-4 flex flex-col stack-y-4">
<SheetBody className="stack-y-4 flex flex-col p-4">
<div className="stack-y-4 mb-4 flex flex-col">
<h3 className="text-emphasis mb-1 text-base font-semibold">{t("profile")}</h3>
<DisplayInfo
label="Cal"
@@ -108,10 +114,10 @@ export function EditUserSheet({
icon="calendar"
/>
</div>
{usersAttributes && usersAttributes?.length > 0 && (
{canViewAttributes && usersAttributes && usersAttributes?.length > 0 && (
<div className="mt-4 flex flex-col">
<h3 className="text-emphasis mb-5 text-base font-semibold">{t("attributes")}</h3>
<div className="flex flex-col stack-y-4">
<div className="stack-y-4 flex flex-col">
{usersAttributes.map((attribute, index) => (
<>
<DisplayInfo
@@ -130,7 +136,10 @@ export function EditUserSheet({
)}
</SheetBody>
<SheetFooter>
<SheetFooterControls />
<SheetFooterControls
canEditAttributesForUser={canEditAttributesForUser}
canChangeMemberRole={canChangeMemberRole}
/>
</SheetFooter>
</>
) : (
@@ -140,6 +149,7 @@ export function EditUserSheet({
avatarUrl={loadedUser.avatarUrl ?? avatarURL}
domainUrl={orgBranding?.fullDomain ?? WEBAPP_URL}
dispatch={dispatch}
canEditAttributesForUser={canEditAttributesForUser}
/>
</>
)}
@@ -28,10 +28,19 @@ function EditModeFooter() {
);
}
function MoreInfoFooter() {
function MoreInfoFooter({
canEditAttributesForUser,
canChangeMemberRole,
}: {
canEditAttributesForUser?: boolean;
canChangeMemberRole?: boolean;
}) {
const { t } = useLocale();
const setEditMode = useEditMode((state) => state.setEditMode);
// Show edit button if user can change member role (edit user info) or edit attributes
const canEdit = canChangeMemberRole || canEditAttributesForUser;
return (
<>
<SheetClose asChild>
@@ -39,21 +48,40 @@ function MoreInfoFooter() {
{t("close")}
</Button>
</SheetClose>
<Button
type="button"
className="justify-center"
onClick={() => {
setEditMode(true);
}}
key="EDIT_BUTTON"
StartIcon="pencil">
{t("edit")}
</Button>
{canEdit && (
<Button
type="button"
className="justify-center"
onClick={() => {
setEditMode(true);
}}
key="EDIT_BUTTON"
StartIcon="pencil">
{t("edit")}
</Button>
)}
</>
);
}
export function SheetFooterControls() {
export function SheetFooterControls({
canEditAttributesForUser,
canChangeMemberRole,
}: {
canEditAttributesForUser?: boolean;
canChangeMemberRole?: boolean;
}) {
const editMode = useEditMode((state) => state.editMode);
return <>{editMode ? <EditModeFooter /> : <MoreInfoFooter />}</>;
return (
<>
{editMode ? (
<EditModeFooter />
) : (
<MoreInfoFooter
canEditAttributesForUser={canEditAttributesForUser}
canChangeMemberRole={canChangeMemberRole}
/>
)}
</>
);
}
@@ -449,16 +449,19 @@ function UserListTableContent({
const isSelf = user.id === session?.user.id;
const permissionsForUser = {
canEdit: permissionsRaw.canEdit && user.accepted && !isSelf,
canRemove: permissionsRaw.canRemove && !isSelf,
canEdit:
((permissionsRaw.canEdit ?? false) || (permissions?.canEditAttributesForUser ?? false)) &&
user.accepted &&
!isSelf,
canRemove: (permissionsRaw.canRemove ?? false) && !isSelf,
canImpersonate:
user.accepted &&
!user.disableImpersonation &&
!isSelf &&
!!org?.canAdminImpersonate &&
permissionsRaw.canImpersonate,
(permissionsRaw.canImpersonate ?? false),
canLeave: user.accepted && isSelf,
canResendInvitation: permissionsRaw.canResendInvitation && !user.accepted,
canResendInvitation: (permissionsRaw.canResendInvitation ?? false) && !user.accepted,
};
return (
@@ -633,7 +636,7 @@ function UserListTableContent({
{t("group_meeting")}
</DataTableSelectionBar.Button>
)}
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
{(permissions?.canEditAttributesForUser ?? adminOrOwner) && (
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
)}
{(permissions?.canChangeMemberRole ?? adminOrOwner) && (
@@ -655,7 +658,15 @@ function UserListTableContent({
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
{state.impersonateMember.showModal && <ImpersonationMemberModal dispatch={dispatch} state={state} />}
{state.changeMemberRole.showModal && <ChangeUserRoleModal dispatch={dispatch} state={state} />}
{state.editSheet.showModal && <EditUserSheet dispatch={dispatch} state={state} />}
{state.editSheet.showModal && (
<EditUserSheet
dispatch={dispatch}
state={state}
canViewAttributes={permissions?.canViewAttributes}
canEditAttributesForUser={permissions?.canEditAttributesForUser}
canChangeMemberRole={permissions?.canChangeMemberRole ?? adminOrOwner}
/>
)}
{ctaContainerRef.current &&
createPortal(
@@ -59,4 +59,6 @@ export interface MemberPermissions {
canChangeMemberRole: boolean;
canRemove: boolean;
canImpersonate: boolean;
canEditAttributesForUser?: boolean;
canViewAttributes?: boolean;
}
@@ -0,0 +1,10 @@
-- Insert editUsers permission for admin role
INSERT INTO "RolePermission" (id, "roleId", resource, action, "createdAt")
SELECT
gen_random_uuid(), 'admin_role', resource, action, NOW()
FROM (
VALUES
-- Attribute editUsers permission
('organization.attributes', 'editUsers')
) AS permissions(resource, action)
ON CONFLICT ("roleId", resource, action) DO NOTHING;
@@ -1,6 +1,6 @@
import type { z } from "zod";
import authedProcedure, { authedOrgAdminProcedure } from "../../../procedures/authedProcedure";
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { assignUserToAttributeSchema } from "./assignUserToAttribute.schema";
import { bulkAssignAttributesSchema } from "./bulkAssignAttributes.schema";
@@ -11,6 +11,7 @@ import { ZFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMem
import { getAttributeSchema } from "./get.schema";
import { getByUserIdSchema } from "./getByUserId.schema";
import { toggleActiveSchema } from "./toggleActive.schema";
import { createAttributePbacProcedure } from "./util";
export type TFindTeamMembersMatchingAttributeLogicInputSchema = z.infer<
typeof ZFindTeamMembersMatchingAttributeLogicInputSchema
@@ -30,31 +31,39 @@ export const attributesRouter = router({
return handler({ ctx, input });
}),
// Mutations
create: authedOrgAdminProcedure.input(createAttributeSchema).mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./create.handler");
return handler({ ctx, input });
}),
edit: authedOrgAdminProcedure.input(editAttributeSchema).mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./edit.handler");
return handler({ ctx, input });
}),
delete: authedOrgAdminProcedure.input(deleteAttributeSchema).mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./delete.handler");
return handler({ ctx, input });
}),
toggleActive: authedOrgAdminProcedure.input(toggleActiveSchema).mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./toggleActive.handler");
return handler({ ctx, input });
}),
create: createAttributePbacProcedure("organization.attributes.create")
.input(createAttributeSchema)
.mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./create.handler");
return handler({ ctx, input });
}),
edit: createAttributePbacProcedure("organization.attributes.update")
.input(editAttributeSchema)
.mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./edit.handler");
return handler({ ctx, input });
}),
delete: createAttributePbacProcedure("organization.attributes.delete")
.input(deleteAttributeSchema)
.mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./delete.handler");
return handler({ ctx, input });
}),
toggleActive: createAttributePbacProcedure("organization.attributes.update")
.input(toggleActiveSchema)
.mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./toggleActive.handler");
return handler({ ctx, input });
}),
assignUserToAttribute: authedOrgAdminProcedure
assignUserToAttribute: createAttributePbacProcedure("organization.attributes.editUsers")
.input(assignUserToAttributeSchema)
.mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./assignUserToAttribute.handler");
return handler({ ctx, input });
}),
bulkAssignAttributes: authedOrgAdminProcedure
bulkAssignAttributes: createAttributePbacProcedure("organization.attributes.editUsers")
.input(bulkAssignAttributesSchema)
.mutation(async ({ ctx, input }) => {
const { default: handler } = await import("./bulkAssignAttributes.handler");
@@ -0,0 +1,46 @@
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import type { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import authedProcedure from "../../../procedures/authedProcedure";
/**
* Creates an attribute procedure with configurable PBAC permissions for organization-scoped operations
* @param permission - The specific permission required (e.g., "organization.attributes.editUsers", "organization.attributes.create")
* @param fallbackRoles - Roles to check when PBAC is disabled (defaults to ["ADMIN", "OWNER"])
* @returns A procedure that checks the specified permission
*/
export const createAttributePbacProcedure = (
permission: PermissionString,
fallbackRoles: MembershipRole[] = ["ADMIN", "OWNER"]
) => {
return authedProcedure.use(async ({ ctx, next }) => {
const org = ctx.user.organization;
if (!org?.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be part of an organization to use this feature",
});
}
const permissionCheckService = new PermissionCheckService();
const hasPermission = await permissionCheckService.checkPermission({
userId: ctx.user.id,
teamId: org.id,
permission,
fallbackRoles,
});
if (!hasPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Permission required: ${permission}`,
});
}
return next();
});
};