Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/api-v4/src/iam/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,5 @@ export interface Roles {
}

export type IamAccessType = keyof IamAccountRoles;

export type PickPermissions<T extends PermissionType> = T;
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-12714-changed-1755514005125.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Changed
---

Update RBAC IAM Users Permissions ([#12714](https://github.com/linode/manager/pull/12714))
33 changes: 29 additions & 4 deletions packages/manager/src/features/IAM/Roles/Roles.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,33 @@ import { RolesLanding } from './Roles';

const queryMocks = vi.hoisted(() => ({
useAccountRoles: vi.fn().mockReturnValue({}),
usePermissions: vi.fn().mockReturnValue({}),
}));

vi.mock('@linode/queries', async () => {
const actual = await vi.importActual<any>('@linode/queries');
const actual = await vi.importActual('@linode/queries');
return {
...actual,
useAccountRoles: queryMocks.useAccountRoles,
};
});

vi.mock('src/features/IAM/Shared/utilities', async () => {
const actual = await vi.importActual<any>(
'src/features/IAM/Shared/utilities'
);
const actual = await vi.importActual('src/features/IAM/Shared/utilities');
return {
...actual,
mapAccountPermissionsToRoles: vi.fn(),
};
});

vi.mock('src/features/IAM/hooks/usePermissions', async () => {
const actual = await vi.importActual('src/features/IAM/hooks/usePermissions');
return {
...actual,
usePermissions: queryMocks.usePermissions,
};
});

beforeEach(() => {
vi.clearAllMocks();
});
Expand All @@ -46,6 +53,11 @@ describe('RolesLanding', () => {

it('renders roles table when permissions are loaded', async () => {
const mockPermissions = accountRolesFactory.build();
queryMocks.usePermissions.mockReturnValue({
data: {
is_account_admin: true,
},
});
queryMocks.useAccountRoles.mockReturnValue({
data: mockPermissions,
isLoading: false,
Expand All @@ -55,4 +67,17 @@ describe('RolesLanding', () => {
// RolesTable has a textbox at the top
expect(screen.getByRole('textbox')).toBeInTheDocument();
});

it('should show an error message if user does not have permissions', () => {
queryMocks.usePermissions.mockReturnValue({
data: {
is_account_admin: false,
},
});

renderWithTheme(<RolesLanding />);
expect(
screen.getByText('You do not have permission to view roles.')
).toBeInTheDocument();
});
});
15 changes: 13 additions & 2 deletions packages/manager/src/features/IAM/Roles/Roles.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { useAccountRoles } from '@linode/queries';
import { CircleProgress, Paper, Typography } from '@linode/ui';
import { CircleProgress, Notice, Paper, Typography } from '@linode/ui';
import React from 'react';

import { RolesTable } from 'src/features/IAM/Roles/RolesTable/RolesTable';
import { mapAccountPermissionsToRoles } from 'src/features/IAM/Shared/utilities';

import { usePermissions } from '../hooks/usePermissions';

export const RolesLanding = () => {
const { data: accountRoles, isLoading } = useAccountRoles();
const { data: permissions } = usePermissions('account', ['is_account_admin']);
const { data: accountRoles, isLoading } = useAccountRoles(
Comment thread
abailly-akamai marked this conversation as resolved.
permissions?.is_account_admin
);

const { roles } = React.useMemo(() => {
if (!accountRoles) {
Expand All @@ -20,6 +25,12 @@ export const RolesLanding = () => {
return <CircleProgress />;
}

if (!permissions?.is_account_admin) {
return (
<Notice variant="error">You do not have permission to view roles.</Notice>
);
}

return (
<Paper sx={(theme) => ({ marginTop: theme.tokens.spacing.S16 })}>
<Typography variant="h2">Roles</Typography>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { RolesTable } from './RolesTable';

import type { RoleView } from '../../Shared/types';

const queryMocks = {
usePermissions: vi.fn(),
};

vi.mock('src/features/IAM/Shared/utilities', async () => {
const actual = await vi.importActual<any>(
'src/features/IAM/Shared/utilities'
Expand All @@ -17,6 +21,16 @@ vi.mock('src/features/IAM/Shared/utilities', async () => {
};
});

vi.mock('src/features/IAM/hooks/usePermissions', async () => {
const actual = await vi.importActual<any>(
'src/features/IAM/hooks/usePermissions'
);
return {
...actual,
usePermissions: vi.fn().mockReturnValue({}),
};
});

const mockRoles: RoleView[] = [
{
access: 'account_access',
Expand All @@ -40,6 +54,11 @@ const mockRoles: RoleView[] = [

beforeEach(() => {
vi.clearAllMocks();
queryMocks.usePermissions.mockReturnValue({
data: {
is_account_admin: true,
},
});
});

describe('RolesTable', () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from 'src/features/IAM/Shared/utilities';
import { usePaginationV2 } from 'src/hooks/usePaginationV2';

import { usePermissions } from '../../hooks/usePermissions';
import {
ROLES_LEARN_MORE_LINK,
ROLES_TABLE_PREFERENCE_KEY,
Expand All @@ -43,6 +44,7 @@ interface Props {
roles?: RoleView[];
}
const DEFAULT_PAGE_SIZE = 10;

export const RolesTable = ({ roles = [] }: Props) => {
// Filter string for the search bar
const [filterString, setFilterString] = React.useState('');
Expand All @@ -54,6 +56,9 @@ export const RolesTable = ({ roles = [] }: Props) => {
const [selectedRows, setSelectedRows] = useState<RoleView[]>([]);
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false);

const { data: permissions } = usePermissions('account', ['is_account_admin']);
const isAccountAdmin = permissions?.is_account_admin;

const pagination = usePaginationV2({
currentRoute: '/iam/roles',
initialPage: 1,
Expand Down Expand Up @@ -194,12 +199,14 @@ export const RolesTable = ({ roles = [] }: Props) => {
</Grid>
<Button
buttonType="primary"
disabled={selectedRows.length === 0}
disabled={selectedRows.length === 0 || !isAccountAdmin}
onClick={() => handleAssignSelectedRoles()}
tooltipText={
selectedRows.length === 0
? 'You must select some roles to assign them.'
: undefined
: !isAccountAdmin
? 'You do not have permission to assign roles.'
: undefined
}
>
Assign Selected Roles
Expand Down Expand Up @@ -279,6 +286,7 @@ export const RolesTable = ({ roles = [] }: Props) => {
}}
>
<RolesTableActionMenu
canUpdateUserGrants={isAccountAdmin}
onClick={() => {
assignRoleRow(roleRow);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ beforeEach(() => {

describe('RolesTableActionMenu', () => {
it('renders when used', () => {
renderWithTheme(<RolesTableActionMenu />);
renderWithTheme(
<RolesTableActionMenu canUpdateUserGrants={true} onClick={() => {}} />
);

expect(screen.getByRole('button')).toBeInTheDocument();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,26 @@ import * as React from 'react';
import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';

interface Props {
canUpdateUserGrants: boolean;
onClick?: () => void;
}

export const RolesTableActionMenu = ({ onClick }: Props) => {
export const RolesTableActionMenu = ({
canUpdateUserGrants,
onClick,
}: Props) => {
// This menu has evolved over time to where it isn't much of a menu at all, but rather a single action.
return (
<InlineMenuAction
actionText={'Assign Role'}
buttonHeight={40}
disabled={!canUpdateUserGrants}
onClick={onClick}
tooltip={
!canUpdateUserGrants
? 'You do not have permission to assign roles.'
: undefined
}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('AssignedRolesActionMenu', () => {
handleUnassignRole={mockOnUnassignRole}
handleUpdateEntities={mockOnUpdateEntities}
handleViewEntities={mockOnViewEntities}
permissions={{ is_account_admin: true }}
role={mockAccountRole}
/>
);
Expand All @@ -62,6 +63,7 @@ describe('AssignedRolesActionMenu', () => {
handleUnassignRole={mockOnUnassignRole}
handleUpdateEntities={mockOnUpdateEntities}
handleViewEntities={mockOnViewEntities}
permissions={{ is_account_admin: true }}
role={mockEntityRole}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ import React from 'react';
import { ActionMenu } from 'src/components/ActionMenu/ActionMenu';

import type { ExtendedRoleView } from '../types';
import type { PickPermissions } from '@linode/api-v4';
import type { Action } from 'src/components/ActionMenu/ActionMenu';

type RolesActionsPermissions = PickPermissions<'is_account_admin'>;
interface Props {
handleChangeRole: (role: ExtendedRoleView) => void;
handleUnassignRole: (role: ExtendedRoleView) => void;
handleUpdateEntities: (role: ExtendedRoleView) => void;
handleViewEntities: (role: string) => void;
permissions: Record<RolesActionsPermissions, boolean>;
role: ExtendedRoleView;
}

export const AssignedRolesActionMenu = ({
permissions,
handleChangeRole,
handleUnassignRole,
handleUpdateEntities,
Expand All @@ -22,16 +26,24 @@ export const AssignedRolesActionMenu = ({
}: Props) => {
const accountMenu: Action[] = [
{
disabled: !permissions.is_account_admin,
onClick: () => {
handleChangeRole(role);
},
title: 'Change Role',
tooltip: !permissions.is_account_admin
? 'You do not have permission to change this role.'
: undefined,
},
{
disabled: !permissions.is_account_admin,
onClick: () => {
handleUnassignRole(role);
},
title: 'Unassign Role',
tooltip: !permissions.is_account_admin
? 'You do not have permission to unassign this role.'
: undefined,
},
];

Expand All @@ -41,18 +53,30 @@ export const AssignedRolesActionMenu = ({
title: 'View Entities',
},
{
disabled: !permissions.is_account_admin,
tooltip: !permissions.is_account_admin
? 'You do not have permission to update this role.'
: undefined,
onClick: () => {
handleUpdateEntities(role);
},
title: 'Update List of Entities',
},
{
disabled: !permissions.is_account_admin,
tooltip: !permissions.is_account_admin
? 'You do not have permission to change this role.'
: undefined,
onClick: () => {
handleChangeRole(role);
},
title: 'Change Role',
},
{
disabled: !permissions.is_account_admin,
tooltip: !permissions.is_account_admin
? 'You do not have permission to unassign this role.'
: undefined,
onClick: () => {
handleUnassignRole(role);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { TableSortCell } from 'src/components/TableSortCell/TableSortCell';
import { usePaginationV2 } from 'src/hooks/usePaginationV2';
import { useAccountEntities } from 'src/queries/entities/entities';

import { usePermissions } from '../../hooks/usePermissions';
import { AssignedEntities } from '../../Users/UserRoles/AssignedEntities';
import { AssignNewRoleDrawer } from '../../Users/UserRoles/AssignNewRoleDrawer';
import {
Expand Down Expand Up @@ -73,6 +74,7 @@ export const AssignedRolesTable = () => {
const [order, setOrder] = React.useState<'asc' | 'desc'>('asc');
const [orderBy, setOrderBy] = React.useState<OrderByKeys>('name');
const [isInitialLoad, setIsInitialLoad] = React.useState(true);
const { data: permissions } = usePermissions('account', ['is_account_admin']);

const pagination = usePaginationV2({
currentRoute: '/iam/users/$username/roles',
Expand Down Expand Up @@ -238,6 +240,7 @@ export const AssignedRolesTable = () => {
handleUnassignRole={handleUnassignRole}
handleUpdateEntities={handleUpdateEntities}
handleViewEntities={handleViewEntities}
permissions={permissions}
role={role}
/>
</TableCell>
Expand Down Expand Up @@ -374,7 +377,13 @@ export const AssignedRolesTable = () => {
<Grid sx={{ alignSelf: 'flex-start' }}>
<Button
buttonType="primary"
disabled={!permissions?.is_account_admin}
onClick={() => setIsAssignNewRoleDrawerOpen(true)}
tooltipText={
!permissions?.is_account_admin
? 'You do not have permission to assign roles.'
: undefined
}
>
Assign New Roles
</Button>
Expand Down
Loading