Skip to content
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-13012-fixed-1761654121842.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

IAM - Ensure useEntitiesPermissions does not run for admin users ([#13012](https://github.com/linode/manager/pull/13012))
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export const AddLinodeDrawer = (props: Props) => {

const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled();

const { data, error, isLoading } = useAllFirewallsQuery();
const {
data,
error,
isLoading: isLoadingAllFirewalls,
} = useAllFirewallsQuery();

const firewall = data?.find((firewall) => firewall.id === Number(id));

Expand Down Expand Up @@ -358,9 +362,11 @@ export const AddLinodeDrawer = (props: Props) => {
)}
{localError ? errorNotice() : null}
<LinodeSelect
disabled={isLoading || availableLinodesLoading || disabled}
disabled={
isLoadingAllFirewalls || availableLinodesLoading || disabled
}
helperText={helperText}
loading={availableLinodesLoading}
loading={isLoadingAllFirewalls || availableLinodesLoading}
multiple
onSelectionChange={(linodes) => onSelectionChange(linodes)}
options={linodeOptions}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export const AddNodebalancerDrawer = (props: Props) => {
<NodeBalancerSelect
disabled={isLoading || disabled}
helperText={helperText}
loading={isLoading}
multiple
onSelectionChange={(nodebalancers) =>
setSelectedNodebalancers(nodebalancers)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => {

const { control } = useFormContext<CreateFirewallFormValues>();

const { data: firewalls } = useAllFirewallsQuery(open);
const { data: firewalls, isLoading: isLoadingAllFirewalls } =
useAllFirewallsQuery(open);

const {
data: permissableLinodes,
Expand Down Expand Up @@ -208,12 +209,13 @@ export const CustomFirewallFields = (props: CustomFirewallProps) => {
name="devices.linodes"
render={({ field, fieldState }) => (
<LinodeSelect
disabled={userCannotAddFirewall}
disabled={userCannotAddFirewall || isLoadingAllFirewalls}
errorText={fieldState.error?.message}
helperText={deviceSelectGuidance}
label={
createFlow === 'linode' ? LINODE_CREATE_FLOW_TEXT : 'Linodes'
}
loading={isLoadingAllFirewalls}
multiple
onSelectionChange={(linodes) => {
field.onChange(linodes.map((linode) => linode.id));
Expand Down
1 change: 1 addition & 0 deletions packages/manager/src/features/IAM/hooks/useIsIAMEnabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const useIsIAMEnabled = () => {
isIAMBeta: flags.iam?.beta,
isIAMEnabled: flags?.iam?.enabled && Boolean(roles || permissions),
isLoading: isLoadingRoles || isLoadingPermissions,
accountRoles: roles,
};
};

Expand Down
9 changes: 8 additions & 1 deletion packages/manager/src/features/IAM/hooks/usePermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,13 @@ export function usePermissions<

export type EntityBase = Pick<AccountEntity, 'id' | 'label'>;

/**
* Helper function to get the permissions for a list of entities.
* Used only for restricted users who need to check permissions for each entity.
*
* ⚠️ This is a performance bottleneck for restricted users who have many entities.
* It will need to be deprecated and refactored when we add the ability to filter entities by permission(s).
*/
export const useEntitiesPermissions = <T extends EntityBase>(
entities: T[] | undefined,
entityType: EntityType,
Expand All @@ -255,7 +262,7 @@ export const useEntitiesPermissions = <T extends EntityBase>(
entityType,
entity.id
),
enabled,
enabled: enabled && Boolean(profile?.restricted),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual fix

})),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,40 @@ describe('useQueryWithPermissions', () => {

expect(result.current.isLoading).toBe(true);
});

it('grants all permissions to unrestricted (admin) users without making permission API calls', () => {
const flags = { iam: { beta: true, enabled: true } };
queryMocks.useIsIAMEnabled.mockReturnValue({
isIAMEnabled: true,
isIAMBeta: true,
});
queryMocks.useProfile.mockReturnValue({
data: { username: 'admin-user', restricted: false },
});

const { result } = renderHook(
() =>
useQueryWithPermissions(
baseQueryResult,
'linode',
['update_linode', 'delete_linode', 'reboot_linode'],
true
),
{ wrapper: (ui) => wrapWithTheme(ui, { flags }) }
);

// Verify grants are NOT fetched (IAM is enabled)
expect(queryMocks.useGrants).toHaveBeenCalledWith(false);

// Verify permission queries are disabled (no N API calls for unrestricted users!)
const queryArgs = queryMocks.useQueries.mock.calls[0][0];
expect(
queryArgs.queries.every((q: { enabled?: boolean }) => q.enabled === false)
).toBe(true);

// Unrestricted users should see ALL entities
expect(result.current.data.map((e) => e.id)).toEqual([1, 2]);
expect(result.current.hasFiltered).toBe(false);
expect(result.current.isLoading).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,12 @@ export const AddFirewallForm = (props: Props) => {

const { mutateAsync } = useAddFirewallDeviceMutation();

const { data: availableFirewalls } = useQueryWithPermissions<Firewall>(
useAllFirewallsQuery(),
'firewall',
['create_firewall_device']
);
const {
data: availableFirewalls,
isLoading: isLoadingAllAvailableFirewalls,
} = useQueryWithPermissions<Firewall>(useAllFirewallsQuery(), 'firewall', [
'create_firewall_device',
]);

const form = useForm<Values>({
resolver: yupResolver(schema) as Resolver<Values>,
Expand Down Expand Up @@ -73,8 +74,10 @@ export const AddFirewallForm = (props: Props) => {
name="firewallId"
render={({ field, fieldState }) => (
<FirewallSelect
disabled={isLoadingAllAvailableFirewalls}
errorText={fieldState.error?.message}
label="Firewall"
loading={isLoadingAllAvailableFirewalls}
onChange={(e, value) => field.onChange(value?.id)}
options={availableFirewalls}
placeholder="Select a Firewall"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,13 @@ export const SubnetUnassignLinodesDrawer = React.memo(

const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpcId);
// TODO: change to 'delete_linode_config_profile_interface' once it's available
const { data: filteredLinodes } = useQueryWithPermissions(
useAllLinodesQuery(),
'linode',
['delete_linode'],
open
);
const { data: filteredLinodes, isLoading: isLoadingFilteredLinodes } =
useQueryWithPermissions<Linode>(
useAllLinodesQuery(),
'linode',
['delete_linode'],
open
);
const userCanUnassignLinodes =
permissions.update_vpc && filteredLinodes?.length > 0;

Expand Down Expand Up @@ -362,6 +363,7 @@ export const SubnetUnassignLinodesDrawer = React.memo(
disabled={!userCanUnassignLinodes}
errorText={linodesError ? linodesError[0].reason : undefined}
label="Linodes"
loading={isLoadingFilteredLinodes}
multiple
onChange={(_, value) => {
setSelectedLinodes(value);
Expand Down