Skip to content

Commit a67c9ac

Browse files
feat: [UIE-9141] - IAM RBAC: add perm check for nodebalancer landing (linode#12780)
* feat: [UIE-9141] - IAM RBAC: add perm check for nodebalancer * Added changeset: IAM RBAC: This PR implements IAM RBAC permissions for NodeBalancer * fix test and add changeset
1 parent eab5e74 commit a67c9ac

13 files changed

+252
-75
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/api-v4": Added
3+
---
4+
5+
NodeBalancers IAM RBAC permissions ([#12780](https://github.com/linode/manager/pull/12780))

packages/api-v4/src/iam/types.ts

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export type AccountAdmin =
102102
| AccountBillingAdmin
103103
| AccountFirewallAdmin
104104
| AccountLinodeAdmin
105+
| AccountNodeBalancerAdmin
105106
| AccountOauthClientAdmin
106107
| AccountVolumeAdmin;
107108

@@ -142,6 +143,14 @@ export type AccountLinodeAdmin = AccountLinodeCreator | LinodeAdmin;
142143
/** Permissions associated with the "account_linode_creator" role. */
143144
export type AccountLinodeCreator = 'create_linode';
144145

146+
/** Permissions associated with the "account_nodebalancer_admin" role. */
147+
export type AccountNodeBalancerAdmin =
148+
| AccountNodeBalancerCreator
149+
| NodeBalancerAdmin;
150+
151+
/** Permissions associated with the "account_nodebalancer_creator" role. */
152+
export type AccountNodeBalancerCreator = 'create_nodebalancer';
153+
145154
/** Permissions associated with the "account_volume_admin" role. */
146155
export type AccountVolumeAdmin = AccountVolumeCreator | VolumeAdmin;
147156

@@ -295,6 +304,35 @@ export type LinodeViewer =
295304
| 'view_linode_network_transfer'
296305
| 'view_linode_stats';
297306

307+
/** Permissions associated with the "nodebalancer_admin" role. */
308+
// TODO: UIE-9154 - verify mapping for Nodebalancer as this is not migrated yet
309+
export type NodeBalancerAdmin =
310+
| 'delete_nodebalancer'
311+
| 'delete_nodebalancer_config'
312+
| 'delete_nodebalancer_config_node'
313+
| NodeBalancerContributor;
314+
315+
/** Permissions associated with the "nodebalancer_contributor" role. */
316+
export type NodeBalancerContributor =
317+
| 'create_nodebalancer_config'
318+
| 'create_nodebalancer_config_node'
319+
| 'rebuild_nodebalancer_config'
320+
| 'update_nodebalancer'
321+
| 'update_nodebalancer_config'
322+
| 'update_nodebalancer_config_node'
323+
| 'update_nodebalancer_firewalls'
324+
| NodeBalancerViewer;
325+
326+
/** Permissions associated with the "nodebalancer_viewer" role. */
327+
export type NodeBalancerViewer =
328+
| 'list_nodebalancer_config_nodes'
329+
| 'list_nodebalancer_configs'
330+
| 'list_nodebalancer_firewalls'
331+
| 'view_nodebalancer'
332+
| 'view_nodebalancer_config'
333+
| 'view_nodebalancer_config_node'
334+
| 'view_nodebalancer_statistics';
335+
298336
/** Permissions associated with the "volume_admin" role. */
299337
export type VolumeAdmin = 'delete_volume' | VolumeContributor;
300338

@@ -311,46 +349,6 @@ export type VolumeContributor =
311349
/** Permissions associated with the "volume_viewer" role. */
312350
export type VolumeViewer = 'view_volume';
313351

314-
/** Facade roles represent the existing Grant model for entities that are not yet migrated to IAM */
315-
export type AccountRoleFacade =
316-
| 'account_database_creator'
317-
| 'account_domain_creator'
318-
| 'account_image_creator'
319-
| 'account_ip_admin'
320-
| 'account_ip_viewer'
321-
| 'account_lkecluster_creator'
322-
| 'account_longview_creator'
323-
| 'account_longview_subscription_admin'
324-
| 'account_nodebalancer_creator'
325-
| 'account_placement_group_creator'
326-
| 'account_stackscript_creator'
327-
| 'account_vlan_admin'
328-
| 'account_vlan_viewer'
329-
| 'account_volume_creator'
330-
| 'account_vpc_creator';
331-
332-
/** Facade roles represent the existing Grant model for entities that are not yet migrated to IAM */
333-
export type EntityRoleFacade =
334-
| 'database_admin'
335-
| 'database_viewer'
336-
| 'domain_admin'
337-
| 'domain_viewer'
338-
| 'image_admin'
339-
| 'image_viewer'
340-
| 'lkecluster_admin'
341-
| 'lkecluster_viewer'
342-
| 'longview_admin'
343-
| 'longview_viewer'
344-
| 'nodebalancer_admin'
345-
| 'nodebalancer_viewer'
346-
| 'placement_group_admin'
347-
| 'placement_group_viewer'
348-
| 'stackscript_admin'
349-
| 'stackscript_viewer'
350-
| 'volume_admin'
351-
| 'volume_viewer'
352-
| 'vpc_admin';
353-
354352
/** Union of all permissions */
355353
export type PermissionType = AccountAdmin;
356354

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Added
3+
---
4+
5+
IAM RBAC: Implements IAM RBAC permissions for NodeBalancer ([#12780](https://github.com/linode/manager/pull/12780))

packages/manager/src/features/IAM/hooks/adapters/accountGrantsToPermissions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ export const accountGrantsToPermissions = (
6969
create_linode: unrestricted || globalGrants?.add_linodes,
7070
// AccountVolumeAdmin
7171
create_volume: unrestricted || globalGrants?.add_volumes,
72+
// AccountNodeBalancerAdmin
73+
create_nodebalancer: unrestricted || globalGrants?.add_nodebalancers,
7274
// AccountOAuthClientAdmin
7375
create_oauth_client: true,
7476
update_oauth_client: true,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { GrantLevel, NodeBalancerAdmin } from '@linode/api-v4';
2+
3+
/** Map the existing Grant model to the new IAM RBAC model. */
4+
export const nodeBalancerGrantsToPermissions = (
5+
grantLevel?: GrantLevel,
6+
isRestricted?: boolean
7+
): Record<NodeBalancerAdmin, boolean> => {
8+
const unrestricted = isRestricted === false; // explicit === false
9+
return {
10+
delete_nodebalancer: unrestricted || grantLevel === 'read_write',
11+
delete_nodebalancer_config: unrestricted || grantLevel === 'read_write',
12+
delete_nodebalancer_config_node:
13+
unrestricted || grantLevel === 'read_write',
14+
update_nodebalancer: unrestricted || grantLevel === 'read_write',
15+
create_nodebalancer_config: unrestricted || grantLevel === 'read_write',
16+
update_nodebalancer_config: unrestricted || grantLevel === 'read_write',
17+
rebuild_nodebalancer_config: unrestricted || grantLevel === 'read_write',
18+
create_nodebalancer_config_node:
19+
unrestricted || grantLevel === 'read_write',
20+
update_nodebalancer_config_node:
21+
unrestricted || grantLevel === 'read_write',
22+
update_nodebalancer_firewalls: unrestricted || grantLevel === 'read_write',
23+
view_nodebalancer: unrestricted || grantLevel !== null,
24+
list_nodebalancer_firewalls: unrestricted || grantLevel !== null,
25+
view_nodebalancer_statistics: unrestricted || grantLevel !== null,
26+
list_nodebalancer_configs: unrestricted || grantLevel !== null,
27+
view_nodebalancer_config: unrestricted || grantLevel !== null,
28+
list_nodebalancer_config_nodes: unrestricted || grantLevel !== null,
29+
view_nodebalancer_config_node: unrestricted || grantLevel !== null,
30+
};
31+
};

packages/manager/src/features/IAM/hooks/adapters/permissionAdapters.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { accountGrantsToPermissions } from './accountGrantsToPermissions';
22
import { firewallGrantsToPermissions } from './firewallGrantsToPermissions';
33
import { linodeGrantsToPermissions } from './linodeGrantsToPermissions';
4+
import { nodeBalancerGrantsToPermissions } from './nodeBalancerGrantsToPermissions';
45
import { volumeGrantsToPermissions } from './volumeGrantsToPermissions';
56

67
import type { EntityBase } from '../usePermissions';
@@ -38,6 +39,10 @@ export const entityPermissionMapFrom = (
3839
entity?.permissions,
3940
profile?.restricted
4041
) as PermissionMap;
42+
const nodebalancerPermissionsMap = nodeBalancerGrantsToPermissions(
43+
entity?.permissions,
44+
profile?.restricted
45+
) as PermissionMap;
4146

4247
/** Add entity permissions to map */
4348
switch (grantType) {
@@ -47,6 +52,9 @@ export const entityPermissionMapFrom = (
4752
case 'linode':
4853
entityPermissionsMap[entity.id] = linodePermissionsMap;
4954
break;
55+
case 'nodebalancer':
56+
entityPermissionsMap[entity.id] = nodebalancerPermissionsMap;
57+
break;
5058
case 'volume':
5159
entityPermissionsMap[entity.id] = volumePermissionsMap;
5260
break;
@@ -68,6 +76,7 @@ export const fromGrants = (
6876
const firewall = grants?.firewall.find((f) => f.id === entityId);
6977
const linode = grants?.linode.find((f) => f.id === entityId);
7078
const volume = grants?.volume.find((f) => f.id === entityId);
79+
const nodebalancer = grants?.nodebalancer.find((f) => f.id === entityId);
7180

7281
let usersPermissionsMap = {} as PermissionMap;
7382

@@ -91,6 +100,12 @@ export const fromGrants = (
91100
isRestricted
92101
) as PermissionMap;
93102
break;
103+
case 'nodebalancer':
104+
usersPermissionsMap = nodeBalancerGrantsToPermissions(
105+
nodebalancer?.permissions,
106+
isRestricted
107+
) as PermissionMap;
108+
break;
94109
case 'volume':
95110
usersPermissionsMap = volumeGrantsToPermissions(
96111
volume?.permissions,

packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ const navigate = vi.fn();
99
const queryMocks = vi.hoisted(() => ({
1010
useNavigate: vi.fn(() => navigate),
1111
useRouter: vi.fn(() => vi.fn()),
12+
userPermissions: vi.fn(() => ({
13+
data: {
14+
delete_nodebalancer: false,
15+
},
16+
})),
17+
}));
18+
19+
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
20+
usePermissions: queryMocks.userPermissions,
1221
}));
1322

1423
vi.mock('@tanstack/react-router', async () => {
@@ -41,7 +50,41 @@ describe('NodeBalancerActionMenu', () => {
4150
expect(getByText('Delete')).toBeVisible();
4251
});
4352

53+
it('should disable "Delete" if the user does not have permissions', async () => {
54+
const { getAllByRole, getByText } = renderWithTheme(
55+
<NodeBalancerActionMenu {...props} />
56+
);
57+
const actionBtn = getAllByRole('button')[0];
58+
expect(actionBtn).toBeInTheDocument();
59+
await userEvent.click(actionBtn);
60+
61+
const deleteBtn = getByText('Delete');
62+
expect(deleteBtn).toHaveAttribute('aria-disabled', 'true');
63+
});
64+
65+
it('should enable "Delete" if the user has permissions', async () => {
66+
queryMocks.userPermissions.mockReturnValue({
67+
data: {
68+
delete_nodebalancer: true,
69+
},
70+
});
71+
const { getAllByRole, getByText } = renderWithTheme(
72+
<NodeBalancerActionMenu {...props} />
73+
);
74+
const actionBtn = getAllByRole('button')[0];
75+
expect(actionBtn).toBeInTheDocument();
76+
await userEvent.click(actionBtn);
77+
78+
const deleteBtn = getByText('Delete');
79+
expect(deleteBtn).not.toHaveAttribute('aria-disabled', 'true');
80+
});
81+
4482
it('triggers the action to delete the NodeBalancer', async () => {
83+
queryMocks.userPermissions.mockReturnValue({
84+
data: {
85+
delete_nodebalancer: true,
86+
},
87+
});
4588
const { getByText } = renderWithTheme(
4689
<NodeBalancerActionMenu {...props} />
4790
);

packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as React from 'react';
66
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';
77
import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
88
import { getRestrictedResourceText } from 'src/features/Account/utils';
9-
import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted';
9+
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
1010

1111
import { useIsNodebalancerVPCEnabled } from '../utils';
1212

@@ -24,11 +24,11 @@ export const NodeBalancerActionMenu = (props: Props) => {
2424

2525
const { nodeBalancerId } = props;
2626

27-
const isNodeBalancerReadOnly = useIsResourceRestricted({
28-
grantLevel: 'read_only',
29-
grantType: 'nodebalancer',
30-
id: nodeBalancerId,
31-
});
27+
const { data: permissions } = usePermissions(
28+
'nodebalancer',
29+
['delete_nodebalancer'],
30+
nodeBalancerId
31+
);
3232

3333
const { isNodebalancerVPCEnabled } = useIsNodebalancerVPCEnabled();
3434

@@ -56,7 +56,7 @@ export const NodeBalancerActionMenu = (props: Props) => {
5656
title: 'Settings',
5757
},
5858
{
59-
disabled: isNodeBalancerReadOnly,
59+
disabled: !permissions.delete_nodebalancer,
6060
onClick: () => {
6161
navigate({
6262
params: {
@@ -66,7 +66,7 @@ export const NodeBalancerActionMenu = (props: Props) => {
6666
});
6767
},
6868
title: 'Delete',
69-
tooltip: isNodeBalancerReadOnly
69+
tooltip: !permissions.delete_nodebalancer
7070
? getRestrictedResourceText({
7171
action: 'delete',
7272
resourceType: 'NodeBalancers',

packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ import { NodeBalancerTableRow } from './NodeBalancerTableRow';
1111
const navigate = vi.fn();
1212
const queryMocks = vi.hoisted(() => ({
1313
useNavigate: vi.fn(() => navigate),
14+
userPermissions: vi.fn(() => ({
15+
data: {
16+
delete_nodebalancer: false,
17+
},
18+
})),
19+
}));
20+
21+
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
22+
usePermissions: queryMocks.userPermissions,
1423
}));
1524

1625
vi.mock('@tanstack/react-router', async () => {
@@ -56,6 +65,11 @@ describe('NodeBalancerTableRow', () => {
5665
});
5766

5867
it('deletes the NodeBalancer', async () => {
68+
queryMocks.userPermissions.mockReturnValue({
69+
data: {
70+
delete_nodebalancer: true,
71+
},
72+
});
5973
const { getByText } = renderWithTheme(<NodeBalancerTableRow {...props} />);
6074

6175
const deleteButton = getByText('Delete');

0 commit comments

Comments
 (0)