Skip to content

Commit f7989de

Browse files
Change: [UIE-9059] - Volumes RBAC permissions (linode#12744)
* adapters + volumes landing * Action menu * remaining instances * fix units * oops the mapping! * cleanup * remove wrong check * changesets * feedback @bnussman-akamai * first round of feedback * feedback 2 * missing grant hooks
1 parent 8c91ede commit f7989de

22 files changed

+307
-146
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+
Volumes IAM RBAC permissions ([#12744](https://github.com/linode/manager/pull/12744))

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ export type AccountAdmin =
102102
| AccountBillingAdmin
103103
| AccountFirewallAdmin
104104
| AccountLinodeAdmin
105-
| AccountOauthClientAdmin;
105+
| AccountOauthClientAdmin
106+
| AccountVolumeAdmin;
106107

107108
/** Permissions associated with the "account_billing_admin" role. */
108109
export type AccountBillingAdmin =
@@ -141,6 +142,12 @@ export type AccountLinodeAdmin = AccountLinodeCreator | LinodeAdmin;
141142
/** Permissions associated with the "account_linode_creator" role. */
142143
export type AccountLinodeCreator = 'create_linode';
143144

145+
/** Permissions associated with the "account_volume_admin" role. */
146+
export type AccountVolumeAdmin = AccountVolumeCreator | VolumeAdmin;
147+
148+
/** Permissions associated with the "account_volume_creator" role. */
149+
export type AccountVolumeCreator = 'create_volume';
150+
144151
/** Permissions associated with the "account_maintenance_viewer" role. */
145152
export type AccountMaintenanceViewer = 'list_maintenances';
146153

@@ -207,7 +214,8 @@ export type AccountViewer =
207214
| AccountOauthClientViewer
208215
| AccountProfileViewer
209216
| FirewallViewer
210-
| LinodeViewer;
217+
| LinodeViewer
218+
| VolumeViewer;
211219

212220
/** Permissions associated with the "firewall_admin role. */
213221
export type FirewallAdmin =
@@ -287,6 +295,22 @@ export type LinodeViewer =
287295
| 'view_linode_network_transfer'
288296
| 'view_linode_stats';
289297

298+
/** Permissions associated with the "volume_admin" role. */
299+
export type VolumeAdmin = 'delete_volume' | VolumeContributor;
300+
301+
/** Permissions associated with the "volume_contributor" role. */
302+
export type VolumeContributor =
303+
| 'attach_volume'
304+
| 'clone_volume'
305+
| 'delete_volume'
306+
| 'detach_volume'
307+
| 'resize_volume'
308+
| 'update_volume'
309+
| VolumeViewer;
310+
311+
/** Permissions associated with the "volume_viewer" role. */
312+
export type VolumeViewer = 'view_volume';
313+
290314
/** Facade roles represent the existing Grant model for entities that are not yet migrated to IAM */
291315
export type AccountRoleFacade =
292316
| 'account_database_creator'
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+
Volumes IAM RBAC permissions ([#12744](https://github.com/linode/manager/pull/12744))

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export const accountGrantsToPermissions = (
6767
create_firewall: unrestricted || globalGrants?.add_firewalls,
6868
// AccountLinodeAdmin
6969
create_linode: unrestricted || globalGrants?.add_linodes,
70+
// AccountVolumeAdmin
71+
create_volume: unrestricted || globalGrants?.add_volumes,
7072
// AccountOAuthClientAdmin
7173
create_oauth_client: true,
7274
update_oauth_client: true,

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

Lines changed: 31 additions & 14 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 { volumeGrantsToPermissions } from './volumeGrantsToPermissions';
45

56
import type { EntityBase } from '../usePermissions';
67
import type {
@@ -24,23 +25,31 @@ export const entityPermissionMapFrom = (
2425
const entityPermissionsMap: EntityPermissionMap = {};
2526
if (grants) {
2627
grants[grantType]?.forEach((entity) => {
28+
/** Entity Permissions Maps */
29+
const firewallPermissionsMap = firewallGrantsToPermissions(
30+
entity?.permissions,
31+
profile?.restricted
32+
) as PermissionMap;
33+
const linodePermissionsMap = linodeGrantsToPermissions(
34+
entity?.permissions,
35+
profile?.restricted
36+
) as PermissionMap;
37+
const volumePermissionsMap = volumeGrantsToPermissions(
38+
entity?.permissions,
39+
profile?.restricted
40+
) as PermissionMap;
41+
42+
/** Add entity permissions to map */
2743
switch (grantType) {
2844
case 'firewall':
29-
// eslint-disable-next-line no-case-declarations
30-
const firewallPermissionsMap = firewallGrantsToPermissions(
31-
entity?.permissions,
32-
profile?.restricted
33-
) as PermissionMap;
3445
entityPermissionsMap[entity.id] = firewallPermissionsMap;
3546
break;
3647
case 'linode':
37-
// eslint-disable-next-line no-case-declarations
38-
const linodePermissionsMap = linodeGrantsToPermissions(
39-
entity?.permissions,
40-
profile?.restricted
41-
) as PermissionMap;
4248
entityPermissionsMap[entity.id] = linodePermissionsMap;
4349
break;
50+
case 'volume':
51+
entityPermissionsMap[entity.id] = volumePermissionsMap;
52+
break;
4453
}
4554
});
4655
}
@@ -55,8 +64,14 @@ export const fromGrants = (
5564
isRestricted?: boolean,
5665
entityId?: number
5766
): PermissionMap => {
67+
/** Find the entity in the grants */
68+
const firewall = grants?.firewall.find((f) => f.id === entityId);
69+
const linode = grants?.linode.find((f) => f.id === entityId);
70+
const volume = grants?.volume.find((f) => f.id === entityId);
71+
5872
let usersPermissionsMap = {} as PermissionMap;
5973

74+
/** Convert the entity permissions to the new IAM RBAC model */
6075
switch (accessType) {
6176
case 'account':
6277
usersPermissionsMap = accountGrantsToPermissions(
@@ -65,21 +80,23 @@ export const fromGrants = (
6580
) as PermissionMap;
6681
break;
6782
case 'firewall':
68-
// eslint-disable-next-line no-case-declarations
69-
const firewall = grants?.firewall.find((f) => f.id === entityId);
7083
usersPermissionsMap = firewallGrantsToPermissions(
7184
firewall?.permissions,
7285
isRestricted
7386
) as PermissionMap;
7487
break;
7588
case 'linode':
76-
// eslint-disable-next-line no-case-declarations
77-
const linode = grants?.linode.find((f) => f.id === entityId);
7889
usersPermissionsMap = linodeGrantsToPermissions(
7990
linode?.permissions,
8091
isRestricted
8192
) as PermissionMap;
8293
break;
94+
case 'volume':
95+
usersPermissionsMap = volumeGrantsToPermissions(
96+
volume?.permissions,
97+
isRestricted
98+
) as PermissionMap;
99+
break;
83100
default:
84101
throw new Error(`Unknown access type: ${accessType}`);
85102
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { GrantLevel, VolumeAdmin } from '@linode/api-v4';
2+
3+
/** Map the existing Grant model to the new IAM RBAC model. */
4+
export const volumeGrantsToPermissions = (
5+
grantLevel?: GrantLevel,
6+
isRestricted?: boolean
7+
): Record<VolumeAdmin, boolean> => {
8+
const unrestricted = isRestricted === false; // explicit === false since the profile can be undefined
9+
return {
10+
attach_volume: unrestricted || grantLevel === 'read_write',
11+
clone_volume: unrestricted || grantLevel === 'read_write',
12+
delete_volume: unrestricted || grantLevel === 'read_write',
13+
detach_volume: unrestricted || grantLevel === 'read_write',
14+
resize_volume: unrestricted || grantLevel === 'read_write',
15+
update_volume: unrestricted || grantLevel === 'read_write',
16+
view_volume: unrestricted || grantLevel !== null,
17+
};
18+
};

packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const queryMocks = vi.hoisted(() => ({
1616
useNavigate: vi.fn(),
1717
useParams: vi.fn(),
1818
useSearch: vi.fn(),
19+
usePermissions: vi.fn(),
1920
}));
2021

2122
vi.mock('@tanstack/react-router', async () => {
@@ -28,11 +29,20 @@ vi.mock('@tanstack/react-router', async () => {
2829
};
2930
});
3031

32+
vi.mock('src/features/IAM/hooks/usePermissions', async () => {
33+
const actual = await vi.importActual('src/features/IAM/hooks/usePermissions');
34+
return {
35+
...actual,
36+
usePermissions: queryMocks.usePermissions,
37+
};
38+
});
39+
3140
describe('LinodeVolumes', async () => {
3241
beforeEach(() => {
3342
queryMocks.useNavigate.mockReturnValue(vi.fn());
3443
queryMocks.useSearch.mockReturnValue({});
3544
queryMocks.useParams.mockReturnValue({});
45+
queryMocks.usePermissions.mockReturnValue({});
3646
});
3747

3848
const volumes = volumeFactory.buildList(3);

packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeVolumes.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty';
1919
import { TableRowError } from 'src/components/TableRowError/TableRowError';
2020
import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading';
2121
import { TableSortCell } from 'src/components/TableSortCell';
22+
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
2223
import { DeleteVolumeDialog } from 'src/features/Volumes/Dialogs/DeleteVolumeDialog';
2324
import { DetachVolumeDialog } from 'src/features/Volumes/Dialogs/DetachVolumeDialog';
2425
import { CloneVolumeDrawer } from 'src/features/Volumes/Drawers/CloneVolumeDrawer';
@@ -28,7 +29,6 @@ import { ResizeVolumeDrawer } from 'src/features/Volumes/Drawers/ResizeVolumeDra
2829
import { VolumeDetailsDrawer } from 'src/features/Volumes/Drawers/VolumeDetailsDrawer';
2930
import { LinodeVolumeAddDrawer } from 'src/features/Volumes/Drawers/VolumeDrawer/LinodeVolumeAddDrawer';
3031
import { VolumeTableRow } from 'src/features/Volumes/VolumeTableRow';
31-
import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted';
3232
import { useOrderV2 } from 'src/hooks/useOrderV2';
3333
import { usePaginationV2 } from 'src/hooks/usePaginationV2';
3434

@@ -41,12 +41,11 @@ export const LinodeVolumes = () => {
4141
const id = Number(linodeId);
4242

4343
const { data: linode } = useLinodeQuery(id);
44-
45-
const isLinodesGrantReadOnly = useIsResourceRestricted({
46-
grantLevel: 'read_only',
47-
grantType: 'linode',
48-
id,
49-
});
44+
const { data: linodePermissions } = usePermissions(
45+
'linode',
46+
['update_linode'],
47+
linode?.id
48+
);
5049

5150
const { handleOrderChange, order, orderBy } = useOrderV2({
5251
initialRoute: {
@@ -207,8 +206,13 @@ export const LinodeVolumes = () => {
207206
<Typography variant="h3">Volumes</Typography>
208207
<Button
209208
buttonType="primary"
210-
disabled={isLinodesGrantReadOnly}
209+
disabled={!linodePermissions?.update_linode}
211210
onClick={handleCreateVolume}
211+
tooltipText={
212+
!linodePermissions?.update_linode
213+
? 'You do not have permission to create or attach a volume to this Linode.'
214+
: undefined
215+
}
212216
>
213217
Add Volume
214218
</Button>

packages/manager/src/features/Linodes/LinodesDetail/VolumesUpgradeBanner.test.tsx

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

88
import { VolumesUpgradeBanner } from './VolumesUpgradeBanner';
99

10+
const queryMocks = vi.hoisted(() => ({
11+
usePermissions: vi.fn(),
12+
}));
13+
14+
vi.mock('src/features/IAM/hooks/usePermissions', async () => {
15+
const actual = await vi.importActual('src/features/IAM/hooks/usePermissions');
16+
return {
17+
...actual,
18+
usePermissions: queryMocks.usePermissions,
19+
};
20+
});
21+
1022
describe('VolumesUpgradeBanner', () => {
23+
beforeEach(() => {
24+
queryMocks.usePermissions.mockReturnValue({
25+
update_volume: true,
26+
});
27+
});
28+
1129
it('should render if there is an upgradable volume', async () => {
1230
const volume = volumeFactory.build();
1331
const notification = notificationFactory.build({

packages/manager/src/features/Volumes/Drawers/AttachVolumeDrawer.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useAttachVolumeMutation, useGrants } from '@linode/queries';
1+
import { useAttachVolumeMutation } from '@linode/queries';
22
import { LinodeSelect } from '@linode/shared';
33
import {
44
ActionsPanel,
@@ -16,6 +16,7 @@ import { number, object } from 'yup';
1616

1717
import { BLOCK_STORAGE_ENCRYPTION_SETTING_IMMUTABLE_COPY } from 'src/components/Encryption/constants';
1818
import { useIsBlockStorageEncryptionFeatureEnabled } from 'src/components/Encryption/utils';
19+
import { usePermissions } from 'src/features/IAM/hooks/usePermissions';
1920
import { useEventsPollingActions } from 'src/queries/events/events';
2021
import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor';
2122

@@ -46,7 +47,13 @@ export const AttachVolumeDrawer = React.memo((props: Props) => {
4647

4748
const { checkForNewEvents } = useEventsPollingActions();
4849

49-
const { data: grants } = useGrants();
50+
const { data: permissions } = usePermissions(
51+
'volume',
52+
['attach_volume'],
53+
volume?.id
54+
);
55+
56+
const canAttachVolume = permissions?.attach_volume;
5057

5158
const { error, mutateAsync: attachVolume } = useAttachVolumeMutation();
5259

@@ -86,11 +93,6 @@ export const AttachVolumeDrawer = React.memo((props: Props) => {
8693
overwrite: 'Overwrite',
8794
};
8895

89-
const isReadOnly =
90-
grants !== undefined &&
91-
grants.volume.find((grant) => grant.id === volume?.id)?.permissions ===
92-
'read_only';
93-
9496
const hasErrorFor = getAPIErrorFor(
9597
errorResources,
9698
error === null ? undefined : error
@@ -107,7 +109,7 @@ export const AttachVolumeDrawer = React.memo((props: Props) => {
107109
title={`Attach Volume ${volume?.label}`}
108110
>
109111
<form onSubmit={formik.handleSubmit}>
110-
{isReadOnly && (
112+
{!canAttachVolume && (
111113
<Notice
112114
text="You don't have permission to attach this volume."
113115
variant="error"
@@ -116,7 +118,7 @@ export const AttachVolumeDrawer = React.memo((props: Props) => {
116118
{generalError && <Notice text={generalError} variant="error" />}
117119
<LinodeSelect
118120
clearable={false}
119-
disabled={isReadOnly}
121+
disabled={!canAttachVolume}
120122
errorText={
121123
formik.touched.linode_id && formik.errors.linode_id
122124
? formik.errors.linode_id
@@ -137,7 +139,7 @@ export const AttachVolumeDrawer = React.memo((props: Props) => {
137139
</FormHelperText>
138140
)}
139141
<StyledConfigSelect
140-
disabled={isReadOnly || formik.values.linode_id === -1}
142+
disabled={!canAttachVolume || formik.values.linode_id === -1}
141143
error={
142144
formik.touched.config_id && formik.errors.config_id
143145
? formik.errors.config_id
@@ -171,7 +173,7 @@ export const AttachVolumeDrawer = React.memo((props: Props) => {
171173
<ActionsPanel
172174
primaryButtonProps={{
173175
'data-testid': 'submit',
174-
disabled: isReadOnly,
176+
disabled: !canAttachVolume,
175177
label: 'Attach',
176178
loading: formik.isSubmitting,
177179
type: 'submit',

0 commit comments

Comments
 (0)