Skip to content

Commit fe793ca

Browse files
feat: [UIE-9125] - IAM RBAC: VPC Landing page permissions check (#12758)
* IAM RBAC: VPC permission check for Landing Page * IAM RBAC: VPC permission check, unit tests * review fix * add permission check to Edit and Delete drawers * Added changeset: IAM RBAC: VPC Landing Page permissions * add permission check VPC Empty State * review fix
1 parent 849551a commit fe793ca

File tree

15 files changed

+282
-41
lines changed

15 files changed

+282
-41
lines changed

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ export type AccountAdmin =
104104
| AccountLinodeAdmin
105105
| AccountNodeBalancerAdmin
106106
| AccountOauthClientAdmin
107-
| AccountVolumeAdmin;
107+
| AccountVolumeAdmin
108+
| AccountVPCAdmin;
108109

109110
/** Permissions associated with the "account_billing_admin" role. */
110111
export type AccountBillingAdmin =
@@ -137,6 +138,12 @@ export type AccountFirewallAdmin = AccountFirewallCreator | FirewallAdmin;
137138
/** Permissions associated with the "account_firewall_creator" role. */
138139
export type AccountFirewallCreator = 'create_firewall';
139140

141+
/** Permissions associated with the "account_vpc_admin" role. */
142+
export type AccountVPCAdmin = AccountVPCCreator | VPCAdmin;
143+
144+
/** Permissions associated with the "account_vpc_creator" role. */
145+
export type AccountVPCCreator = 'create_vpc';
146+
140147
/** Permissions associated with the "account_linode_admin" role. */
141148
export type AccountLinodeAdmin = AccountLinodeCreator | LinodeAdmin;
142149

@@ -248,6 +255,22 @@ export type FirewallViewer =
248255
| 'view_firewall_device'
249256
| 'view_firewall_rule_version';
250257

258+
/** Permissions associated with the "vpc_admin" role. */
259+
export type VPCAdmin = 'delete_vpc' | 'delete_vpc_subnet' | VPCContributor;
260+
261+
/** Permissions associated with the "vpc_contributor role. */
262+
export type VPCContributor =
263+
| 'create_vpc_subnet'
264+
| 'update_vpc'
265+
| 'update_vpc_subnet'
266+
| VPCViewer;
267+
268+
/** Permissions associated with the "vpc_viewer" role. */
269+
export type VPCViewer =
270+
| 'list_vpc_ip_addresses'
271+
| 'view_vpc'
272+
| 'view_vpc_subnet';
273+
251274
/** Permissions associated with the "linode_admin" role. */
252275
export type LinodeAdmin =
253276
| 'cancel_linode_backups'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
IAM RBAC: VPC Landing Page permissions ([#12758](https://github.com/linode/manager/pull/12758))

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export const accountGrantsToPermissions = (
7171
create_volume: unrestricted || globalGrants?.add_volumes,
7272
// AccountNodeBalancerAdmin
7373
create_nodebalancer: unrestricted || globalGrants?.add_nodebalancers,
74+
// AccountVPCAdmin
75+
create_vpc: unrestricted || globalGrants?.add_vpcs,
7476
// AccountOAuthClientAdmin
7577
create_oauth_client: true,
7678
update_oauth_client: true,

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { firewallGrantsToPermissions } from './firewallGrantsToPermissions';
33
import { linodeGrantsToPermissions } from './linodeGrantsToPermissions';
44
import { nodeBalancerGrantsToPermissions } from './nodeBalancerGrantsToPermissions';
55
import { volumeGrantsToPermissions } from './volumeGrantsToPermissions';
6+
import { vpcGrantsToPermissions } from './vpcGrantsToPermissions';
67

78
import type { EntityBase } from '../usePermissions';
89
import type {
@@ -43,6 +44,10 @@ export const entityPermissionMapFrom = (
4344
entity?.permissions,
4445
profile?.restricted
4546
) as PermissionMap;
47+
const vpcPermissionsMap = vpcGrantsToPermissions(
48+
entity?.permissions,
49+
profile?.restricted
50+
) as PermissionMap;
4651

4752
/** Add entity permissions to map */
4853
switch (grantType) {
@@ -58,6 +63,9 @@ export const entityPermissionMapFrom = (
5863
case 'volume':
5964
entityPermissionsMap[entity.id] = volumePermissionsMap;
6065
break;
66+
case 'vpc':
67+
entityPermissionsMap[entity.id] = vpcPermissionsMap;
68+
break;
6169
}
6270
});
6371
}
@@ -77,6 +85,7 @@ export const fromGrants = (
7785
const linode = grants?.linode.find((f) => f.id === entityId);
7886
const volume = grants?.volume.find((f) => f.id === entityId);
7987
const nodebalancer = grants?.nodebalancer.find((f) => f.id === entityId);
88+
const vpc = grants?.vpc.find((f) => f.id === entityId);
8089

8190
let usersPermissionsMap = {} as PermissionMap;
8291

@@ -112,6 +121,12 @@ export const fromGrants = (
112121
isRestricted
113122
) as PermissionMap;
114123
break;
124+
case 'vpc':
125+
usersPermissionsMap = vpcGrantsToPermissions(
126+
vpc?.permissions,
127+
isRestricted
128+
) as PermissionMap;
129+
break;
115130
default:
116131
throw new Error(`Unknown access type: ${accessType}`);
117132
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { AccountVPCAdmin, GrantLevel } from '@linode/api-v4';
2+
3+
/** Map the existing Grant model to the new IAM RBAC model. */
4+
export const vpcGrantsToPermissions = (
5+
grantLevel?: GrantLevel,
6+
isRestricted?: boolean
7+
): Record<AccountVPCAdmin, boolean> => {
8+
const unrestricted = isRestricted === false; // explicit === false
9+
return {
10+
create_vpc: unrestricted || grantLevel === 'read_write',
11+
create_vpc_subnet: unrestricted || grantLevel === 'read_write',
12+
delete_vpc: unrestricted || grantLevel === 'read_write',
13+
delete_vpc_subnet: unrestricted || grantLevel === 'read_write',
14+
update_vpc: unrestricted || grantLevel === 'read_write',
15+
update_vpc_subnet: unrestricted || grantLevel === 'read_write',
16+
list_vpc_ip_addresses: unrestricted || grantLevel !== null,
17+
view_vpc: unrestricted || grantLevel !== null,
18+
view_vpc_subnet: unrestricted || grantLevel !== null,
19+
};
20+
};

packages/manager/src/features/Linodes/LinodeCreate/Networking/utilities.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,11 @@ export const getDefaultFirewallForInterfacePurpose = (
192192
}
193193

194194
if (purpose === 'public') {
195-
return firewallSettings.default_firewall_ids.public_interface;
195+
return firewallSettings.default_firewall_ids?.public_interface;
196196
}
197197

198198
if (purpose === 'vpc') {
199-
return firewallSettings.default_firewall_ids.vpc_interface;
199+
return firewallSettings.default_firewall_ids?.vpc_interface;
200200
}
201201

202202
return null;

packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ import { renderWithTheme } from 'src/utilities/testHelpers';
66

77
import { VPCDeleteDialog } from './VPCDeleteDialog';
88

9+
const queryMocks = vi.hoisted(() => ({
10+
useVPCsQuery: vi.fn().mockReturnValue({}),
11+
userPermissions: vi.fn(() => ({
12+
data: {
13+
delete_vpc: true,
14+
},
15+
})),
16+
}));
17+
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
18+
usePermissions: queryMocks.userPermissions,
19+
}));
20+
921
describe('VPC Delete Dialog', () => {
1022
const props = {
1123
isFetching: false,
@@ -34,4 +46,26 @@ describe('VPC Delete Dialog', () => {
3446
await userEvent.click(cancelButton);
3547
expect(props.onClose).toBeCalled();
3648
});
49+
50+
it('disables the VPC Label input when user does not have "delete_vpc" permission', async () => {
51+
queryMocks.userPermissions.mockReturnValue({
52+
data: {
53+
delete_vpc: false,
54+
},
55+
});
56+
const view = renderWithTheme(<VPCDeleteDialog {...props} />);
57+
const vpcLabelInput = await view.findByLabelText('VPC Label');
58+
expect(vpcLabelInput).toBeDisabled();
59+
});
60+
61+
it('enables the VPC Label input when user has "delete_vpc" permission', async () => {
62+
queryMocks.userPermissions.mockReturnValue({
63+
data: {
64+
delete_vpc: true,
65+
},
66+
});
67+
const view = renderWithTheme(<VPCDeleteDialog {...props} />);
68+
const vpcLabelInput = await view.findByLabelText('VPC Label');
69+
expect(vpcLabelInput).not.toBeDisabled();
70+
});
3771
});

packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useDeleteVPCMutation } from '@linode/queries';
2+
import { Notice } from '@linode/ui';
23
import { useLocation, useNavigate } from '@tanstack/react-router';
34
import { useSnackbar } from 'notistack';
45
import * as React from 'react';
56

67
import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog';
8+
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
79

810
import type { APIError, VPC } from '@linode/api-v4';
911

@@ -27,6 +29,8 @@ export const VPCDeleteDialog = (props: Props) => {
2729
const navigate = useNavigate();
2830
const location = useLocation();
2931

32+
const { data: permissions } = usePermissions('vpc', ['delete_vpc'], vpc?.id);
33+
3034
React.useEffect(() => {
3135
if (open) {
3236
reset();
@@ -47,6 +51,7 @@ export const VPCDeleteDialog = (props: Props) => {
4751

4852
return (
4953
<TypeToConfirmDialog
54+
disableTypeToConfirmInput={!permissions.delete_vpc}
5055
entity={{
5156
action: 'deletion',
5257
name: vpc?.label,
@@ -63,6 +68,13 @@ export const VPCDeleteDialog = (props: Props) => {
6368
onClose={onClose}
6469
open={open}
6570
title={`Delete VPC${vpc ? ` ${vpc.label}` : ''}`}
66-
/>
71+
>
72+
{!permissions.delete_vpc && (
73+
<Notice
74+
text={`You don't have permissions to delete ${vpc?.label}. Please contact an account administrator for details.`}
75+
variant="error"
76+
/>
77+
)}
78+
</TypeToConfirmDialog>
6779
);
6880
};

packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ import { renderWithTheme } from 'src/utilities/testHelpers';
55

66
import { VPCEditDrawer } from './VPCEditDrawer';
77

8+
const queryMocks = vi.hoisted(() => ({
9+
useVPCsQuery: vi.fn().mockReturnValue({}),
10+
userPermissions: vi.fn(() => ({
11+
data: {
12+
update_vpc: true,
13+
},
14+
})),
15+
}));
16+
vi.mock('src/features/IAM/hooks/usePermissions', () => ({
17+
usePermissions: queryMocks.userPermissions,
18+
}));
819
describe('Edit VPC Drawer', () => {
920
const props = {
1021
isFetching: false,
@@ -38,4 +49,34 @@ describe('Edit VPC Drawer', () => {
3849
expect(cancelBtn).not.toHaveAttribute('aria-disabled', 'true');
3950
expect(cancelBtn).toBeVisible();
4051
});
52+
53+
it('Should disable the Label and Description inputs when user does not have "update_vpc" permission', async () => {
54+
queryMocks.userPermissions.mockReturnValue({
55+
data: {
56+
update_vpc: false,
57+
},
58+
});
59+
const { getByLabelText } = renderWithTheme(<VPCEditDrawer {...props} />);
60+
61+
const labelInput = getByLabelText('Label');
62+
expect(labelInput).toHaveAttribute('disabled');
63+
64+
const descriptionInput = getByLabelText('Description');
65+
expect(descriptionInput).toHaveAttribute('disabled');
66+
});
67+
68+
it('Should enable the Label and Description inputs when user has "update_vpc" permission', async () => {
69+
queryMocks.userPermissions.mockReturnValue({
70+
data: {
71+
update_vpc: true,
72+
},
73+
});
74+
const { getByLabelText } = renderWithTheme(<VPCEditDrawer {...props} />);
75+
76+
const labelInput = getByLabelText('Label');
77+
expect(labelInput).not.toHaveAttribute('disabled');
78+
79+
const descriptionInput = getByLabelText('Description');
80+
expect(descriptionInput).not.toHaveAttribute('disabled');
81+
});
4182
});

packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { yupResolver } from '@hookform/resolvers/yup';
2-
import { useGrants, useProfile, useUpdateVPCMutation } from '@linode/queries';
2+
import { useUpdateVPCMutation } from '@linode/queries';
33
import { ActionsPanel, Drawer, Notice, TextField } from '@linode/ui';
44
import { updateVPCSchema } from '@linode/validation';
55
import * as React from 'react';
66
import { Controller, useForm } from 'react-hook-form';
77

8+
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
9+
810
import type { APIError, UpdateVPCPayload, VPC } from '@linode/api-v4';
911

1012
interface Props {
@@ -18,16 +20,7 @@ interface Props {
1820
export const VPCEditDrawer = (props: Props) => {
1921
const { isFetching, onClose, open, vpc, vpcError } = props;
2022

21-
const { data: profile } = useProfile();
22-
const { data: grants } = useGrants();
23-
24-
const vpcPermissions = grants?.vpc.find((v) => v.id === vpc?.id);
25-
26-
// there isn't a 'view VPC/Subnet' grant that does anything, so all VPCs get returned even for restricted users
27-
// with permissions set to 'None'. Therefore, we're treating those as read_only as well
28-
const readOnly =
29-
Boolean(profile?.restricted) &&
30-
(vpcPermissions?.permissions === 'read_only' || grants?.vpc.length === 0);
23+
const { data: permissions } = usePermissions('vpc', ['update_vpc'], vpc?.id);
3124

3225
const {
3326
isPending,
@@ -78,7 +71,7 @@ export const VPCEditDrawer = (props: Props) => {
7871
{errors.root?.message && (
7972
<Notice text={errors.root.message} variant="error" />
8073
)}
81-
{readOnly && (
74+
{!permissions.update_vpc && (
8275
<Notice
8376
text={`You don't have permissions to edit ${vpc?.label}. Please contact an account administrator for details.`}
8477
variant="error"
@@ -91,7 +84,7 @@ export const VPCEditDrawer = (props: Props) => {
9184
render={({ field, fieldState }) => (
9285
<TextField
9386
data-testid="label"
94-
disabled={readOnly}
87+
disabled={!permissions.update_vpc}
9588
errorText={fieldState.error?.message}
9689
label="Label"
9790
name="label"
@@ -107,7 +100,7 @@ export const VPCEditDrawer = (props: Props) => {
107100
render={({ field, fieldState }) => (
108101
<TextField
109102
data-testid="description"
110-
disabled={readOnly}
103+
disabled={!permissions.update_vpc}
111104
errorText={fieldState.error?.message}
112105
label="Description"
113106
multiline
@@ -121,7 +114,7 @@ export const VPCEditDrawer = (props: Props) => {
121114
<ActionsPanel
122115
primaryButtonProps={{
123116
'data-testid': 'save-button',
124-
disabled: !isDirty || readOnly,
117+
disabled: !isDirty || !permissions.update_vpc,
125118
label: 'Save',
126119
loading: isPending || isSubmitting,
127120
type: 'submit',

0 commit comments

Comments
 (0)