From f3d480d5fbaa81ed28e1d1acbf7f448a9f1850dd Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 14 Aug 2025 15:38:05 +0200 Subject: [PATCH 01/10] implementing basic permissions --- packages/api-v4/src/iam/types.ts | 2 ++ .../manager/src/features/IAM/Roles/Roles.tsx | 15 +++++++-- .../IAM/Users/UserDetails/UserProfile.tsx | 32 +++++++++++++++++-- .../IAM/Users/UserEntities/UserEntities.tsx | 14 ++++++-- .../IAM/Users/UserRoles/UserRoles.tsx | 13 +++++++- .../IAM/Users/UsersTable/ProxyUserTable.tsx | 6 ++-- .../features/IAM/Users/UsersTable/UserRow.tsx | 19 +++++++++-- .../features/IAM/Users/UsersTable/Users.tsx | 13 +++++--- .../Users/UsersTable/UsersActionMenu.test.tsx | 15 +++++++++ .../IAM/Users/UsersTable/UsersActionMenu.tsx | 17 ++++++++-- packages/queries/src/account/users.ts | 4 +-- packages/queries/src/iam/iam.ts | 4 +-- 12 files changed, 129 insertions(+), 25 deletions(-) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 7259c9885af..092b66a4b82 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -357,3 +357,5 @@ export interface Roles { } export type IamAccessType = keyof IamAccountRoles; + +export type PickPermissions = T; diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx index 9b3c0ef197f..cf314102331 100644 --- a/packages/manager/src/features/IAM/Roles/Roles.tsx +++ b/packages/manager/src/features/IAM/Roles/Roles.tsx @@ -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', ['list_user_grants']); + const { data: accountRoles, isLoading } = useAccountRoles( + permissions?.list_user_grants + ); const { roles } = React.useMemo(() => { if (!accountRoles) { @@ -20,6 +25,12 @@ export const RolesLanding = () => { return ; } + if (!permissions?.list_user_grants) { + return ( + You do not have permission to view roles. + ); + } + return ( ({ marginTop: theme.tokens.spacing.S16 })}> Roles diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx index 55d3a0c46e6..d42ec0fabf4 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserProfile.tsx @@ -1,10 +1,17 @@ import { useAccountUser, useUserRoles } from '@linode/queries'; -import { CircleProgress, ErrorState, NotFound, Stack } from '@linode/ui'; +import { + CircleProgress, + ErrorState, + NotFound, + Notice, + Stack, +} from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { usePermissions } from '../../hooks/usePermissions'; import { DeleteUserPanel } from './DeleteUserPanel'; import { UserDetailsPanel } from './UserDetailsPanel'; import { UserEmailPanel } from './UserEmailPanel'; @@ -12,14 +19,33 @@ import { UsernamePanel } from './UsernamePanel'; export const UserProfile = () => { const { username } = useParams({ from: '/iam/users/$username' }); + const { data: permissions } = usePermissions('account', [ + 'view_user', + 'list_user_grants', + ]); - const { data: user, error, isLoading } = useAccountUser(username ?? ''); - const { data: assignedRoles } = useUserRoles(username ?? ''); + const { + data: user, + error, + isLoading, + } = useAccountUser(username ?? '', permissions?.view_user); + const { data: assignedRoles } = useUserRoles( + username ?? '', + permissions?.list_user_grants + ); if (isLoading) { return ; } + if (!permissions?.view_user) { + return ( + + You do not have permission to view this user details. + + ); + } + if (error) { return ; } diff --git a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx index 6ff73df9aa2..af990fe5551 100644 --- a/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx +++ b/packages/manager/src/features/IAM/Users/UserEntities/UserEntities.tsx @@ -2,6 +2,7 @@ import { useAccountUser, useUserRoles } from '@linode/queries'; import { CircleProgress, ErrorState, + Notice, Paper, Typography, useTheme, @@ -11,6 +12,7 @@ import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { usePermissions } from '../../hooks/usePermissions'; import { ERROR_STATE_TEXT, NO_ASSIGNED_ENTITIES_TEXT, @@ -20,13 +22,13 @@ import { AssignedEntitiesTable } from './AssignedEntitiesTable'; export const UserEntities = () => { const theme = useTheme(); - const { username } = useParams({ from: '/iam/users/$username' }); + const { data: permissions } = usePermissions('account', ['list_user_grants']); const { data: assignedRoles, isLoading, error: assignedRolesError, - } = useUserRoles(username ?? ''); + } = useUserRoles(username ?? '', permissions?.list_user_grants); const { error } = useAccountUser(username ?? ''); @@ -38,6 +40,14 @@ export const UserEntities = () => { return ; } + if (!permissions?.list_user_grants) { + return ( + + You do not have permission to view this user entities. + + ); + } + if (error || assignedRolesError) { return ; } diff --git a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx index eb9630843ab..475f7fa668c 100644 --- a/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx +++ b/packages/manager/src/features/IAM/Users/UserRoles/UserRoles.tsx @@ -2,6 +2,7 @@ import { useAccountUser, useUserRoles } from '@linode/queries'; import { CircleProgress, ErrorState, + Notice, Paper, Typography, useTheme, @@ -11,6 +12,7 @@ import React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { usePermissions } from '../../hooks/usePermissions'; import { AssignedRolesTable } from '../../Shared/AssignedRolesTable/AssignedRolesTable'; import { ERROR_STATE_TEXT, @@ -20,13 +22,14 @@ import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles'; export const UserRoles = () => { const { username } = useParams({ from: '/iam/users/$username' }); + const { data: permissions } = usePermissions('account', ['list_user_grants']); const theme = useTheme(); const { data: assignedRoles, isLoading, error: assignedRolesError, - } = useUserRoles(username ?? ''); + } = useUserRoles(username ?? '', permissions?.list_user_grants); const { error } = useAccountUser(username ?? ''); @@ -39,6 +42,14 @@ export const UserRoles = () => { return ; } + if (!permissions?.list_user_grants) { + return ( + + You do not have permission to view this user roles. + + ); + } + if (error || assignedRolesError) { return ; } diff --git a/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx b/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx index d4b7b41c2c1..6895a04a313 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/ProxyUserTable.tsx @@ -12,16 +12,16 @@ import { UsersLandingTableBody } from './UsersLandingTableBody'; import type { Order } from './UsersLandingTableHead'; interface Props { + canListUsers: boolean | undefined; handleDelete: (username: string) => void; isProxyUser: boolean; - isRestrictedUser: boolean | undefined; order: Order; } export const ProxyUserTable = ({ handleDelete, isProxyUser, - isRestrictedUser, + canListUsers, order, }: Props) => { const { @@ -29,7 +29,7 @@ export const ProxyUserTable = ({ error: proxyUserError, isLoading: isLoadingProxyUser, } = useAccountUsers({ - enabled: isProxyUser && !isRestrictedUser, + enabled: isProxyUser && canListUsers, filters: { user_type: 'proxy' }, }); diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx index 4ca164aa96d..ad0ddc51d00 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UserRow.tsx @@ -12,6 +12,7 @@ import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { usePermissions } from '../../hooks/usePermissions'; import { UsersActionMenu } from './UsersActionMenu'; import type { User } from '@linode/api-v4'; @@ -25,6 +26,13 @@ export const UserRow = ({ onDelete, user }: Props) => { const theme = useTheme(); const { data: profile } = useProfile(); + const { data: permissions } = usePermissions('account', [ + 'view_user', + 'delete_user', + 'list_user_grants', + ]); + + const canViewUser = permissions.view_user; const isProxyUser = Boolean(user.user_type === 'proxy'); @@ -42,9 +50,13 @@ export const UserRow = ({ onDelete, user }: Props) => { /> - - {user.username} - + {canViewUser ? ( + + {user.username} + + ) : ( + user.username + )} @@ -68,6 +80,7 @@ export const UserRow = ({ onDelete, user }: Props) => { diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx index e7ba6f24e77..578a667cb4b 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx @@ -13,6 +13,7 @@ import { TableBody } from 'src/components/TableBody'; import { useOrderV2 } from 'src/hooks/useOrderV2'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; +import { usePermissions } from '../../hooks/usePermissions'; import { UserDeleteConfirmation } from '../../Shared/UserDeleteConfirmation'; import { CreateUserDrawer } from './CreateUserDrawer'; import { ProxyUserTable } from './ProxyUserTable'; @@ -32,6 +33,7 @@ export const UsersLanding = () => { const [selectedUsername, setSelectedUsername] = React.useState(''); const { data: profile } = useProfile(); const theme = useTheme(); + const { data: permissions } = usePermissions('account', ['create_user']); const pagination = usePaginationV2({ currentRoute: '/iam/users', initialPage: 1, @@ -77,8 +79,6 @@ export const UsersLanding = () => { }, }); - const isRestrictedUser = profile?.restricted; - const isSmDown = useMediaQuery(theme.breakpoints.down('sm')); const isLgDown = useMediaQuery(theme.breakpoints.up('lg')); @@ -104,13 +104,16 @@ export const UsersLanding = () => { setSelectedUsername(username); }; + const canCreateUser = permissions.create_user; + return ( {isProxyUser && ( )} @@ -152,10 +155,10 @@ export const UsersLanding = () => { )} diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx index 18250bc1c34..6b7ed5453f9 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.test.tsx @@ -25,7 +25,9 @@ describe('UserEmailPanel', () => { it("initializes the form with the user's email", async () => { const user = accountUserFactory.build(); - const { getByLabelText } = renderWithTheme(); + const { getByLabelText } = renderWithTheme( + + ); const emailTextField = getByLabelText('Email'); @@ -43,7 +45,7 @@ describe('UserEmailPanel', () => { ); const { findByLabelText, getByLabelText, getByText } = renderWithTheme( - + ); const warning = await findByLabelText( @@ -68,7 +70,7 @@ describe('UserEmailPanel', () => { }); const { getByLabelText, getByText } = renderWithTheme( - + ); const warning = getByLabelText('This field can’t be modified.'); @@ -92,7 +94,7 @@ describe('UserEmailPanel', () => { username: 'user-1', }); - renderWithTheme(); + renderWithTheme(); const emailInput = screen.getByLabelText('Email'); @@ -105,4 +107,19 @@ describe('UserEmailPanel', () => { const errorText = screen.getByText(/invalid email address/i); expect(errorText).toBeInTheDocument(); }); + + it('disables the save button when the user does not have update_user permission', async () => { + const user = accountUserFactory.build({ + email: 'my-linode-email', + }); + + const { getByRole, findByDisplayValue } = renderWithTheme( + + ); + + await findByDisplayValue(user.email); + + const saveButton = getByRole('button', { name: 'Save' }); + expect(saveButton).toBeDisabled(); + }); }); diff --git a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx index 860bb8a5e7c..fe6c003ee84 100644 --- a/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx +++ b/packages/manager/src/features/IAM/Users/UserDetails/UserEmailPanel.tsx @@ -9,10 +9,11 @@ import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; import type { User } from '@linode/api-v4'; interface Props { + canUpdateUser: boolean; user: User; } -export const UserEmailPanel = ({ user }: Props) => { +export const UserEmailPanel = ({ canUpdateUser, user }: Props) => { const { enqueueSnackbar } = useSnackbar(); const { data: profile } = useProfile(); @@ -73,9 +74,14 @@ export const UserEmailPanel = ({ user }: Props) => { /> diff --git a/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx b/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx index f8a85c53368..6b6ee966918 100644 --- a/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx +++ b/packages/manager/src/features/IAM/Shared/NoAssignedRoles/NoAssignedRoles.tsx @@ -3,6 +3,7 @@ import React from 'react'; import EmptyState from 'src/assets/icons/empty-state-cloud.svg'; +import { usePermissions } from '../../hooks/usePermissions'; import { AssignNewRoleDrawer } from '../../Users/UserRoles/AssignNewRoleDrawer'; interface Props { @@ -13,6 +14,9 @@ interface Props { export const NoAssignedRoles = (props: Props) => { const { text, hasAssignNewRoleDrawer } = props; const theme = useTheme(); + const { data: permissions } = usePermissions('account', [ + 'update_user_grants', + ]); const [isAssignNewRoleDrawerOpen, setIsAssignNewRoleDrawerOpen] = React.useState(false); @@ -42,7 +46,13 @@ export const NoAssignedRoles = (props: Props) => { {hasAssignNewRoleDrawer && ( diff --git a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx index d4e8054bdbd..71083f0a7c6 100644 --- a/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx +++ b/packages/manager/src/features/IAM/Users/UsersTable/UsersActionMenu.tsx @@ -26,6 +26,8 @@ export const UsersActionMenu = (props: Props) => { const { data: profile } = useProfile(); const profileUsername = profile?.username; + const isAccountAdmin = permissions.is_account_admin; + const canDeleteUser = permissions.delete_user; const proxyUserActions: Action[] = [ { @@ -35,7 +37,10 @@ export const UsersActionMenu = (props: Props) => { params: { username }, }); }, - disabled: !permissions.is_account_admin, + disabled: !isAccountAdmin, + tooltip: !isAccountAdmin + ? 'You do not have permission to manage access.' + : undefined, title: 'Manage Access', }, ]; @@ -48,7 +53,10 @@ export const UsersActionMenu = (props: Props) => { params: { username }, }); }, - disabled: !permissions.is_account_admin, + disabled: !isAccountAdmin, + tooltip: !isAccountAdmin + ? 'You do not have permission to view user details.' + : undefined, title: 'View User Details', }, { @@ -58,7 +66,10 @@ export const UsersActionMenu = (props: Props) => { params: { username }, }); }, - disabled: !permissions.is_account_admin, + disabled: !isAccountAdmin, + tooltip: !isAccountAdmin + ? 'You do not have permission to view assigned roles.' + : undefined, title: 'View Assigned Roles', }, { @@ -68,11 +79,14 @@ export const UsersActionMenu = (props: Props) => { params: { username }, }); }, - disabled: !permissions.is_account_admin, + disabled: !isAccountAdmin, + tooltip: !isAccountAdmin + ? 'You do not have permission to view entity access.' + : undefined, title: 'View Entity Access', }, { - disabled: username === profileUsername || !permissions.delete_user, + disabled: username === profileUsername || !canDeleteUser, onClick: () => { onDelete(username); }, @@ -80,7 +94,9 @@ export const UsersActionMenu = (props: Props) => { tooltip: username === profileUsername ? "You can't delete the currently active user." - : undefined, + : !canDeleteUser + ? 'You do not have permission to delete this user.' + : undefined, }, ]; From b210f9f87c2678258a2ff34a8b0ca56c4526a785 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Wed, 20 Aug 2025 12:22:50 +0200 Subject: [PATCH 09/10] feedback @coliu-akamai --- .../AssignedRolesTable/AssignedRolesActionMenu.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx index 4dbaff873e9..9f6a35bf9a9 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx @@ -49,12 +49,14 @@ export const AssignedRolesActionMenu = ({ const entitiesMenu: Action[] = [ { - disabled: !permissions.update_user_grants, onClick: () => handleViewEntities(role.name), title: 'View Entities', }, { disabled: !permissions.update_user_grants, + tooltip: !permissions.update_user_grants + ? 'You do not have permission to update this role.' + : undefined, onClick: () => { handleUpdateEntities(role); }, @@ -62,6 +64,9 @@ export const AssignedRolesActionMenu = ({ }, { disabled: !permissions.update_user_grants, + tooltip: !permissions.update_user_grants + ? 'You do not have permission to change this role.' + : undefined, onClick: () => { handleChangeRole(role); }, @@ -69,6 +74,9 @@ export const AssignedRolesActionMenu = ({ }, { disabled: !permissions.update_user_grants, + tooltip: !permissions.update_user_grants + ? 'You do not have permission to unassign this role.' + : undefined, onClick: () => { handleUnassignRole(role); }, From aba7649c8ef07e7d1b595b4c1792985e3670dcad Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Wed, 20 Aug 2025 16:42:38 +0200 Subject: [PATCH 10/10] feedback @corya-akamai --- .../IAM/Roles/RolesTable/RolesTable.tsx | 12 +++++----- .../AssignedRolesActionMenu.test.tsx | 4 ++-- .../AssignedRolesActionMenu.tsx | 22 +++++++++---------- .../AssignedRolesTable/AssignedRolesTable.tsx | 8 +++---- .../NoAssignedRoles/NoAssignedRoles.tsx | 8 +++---- .../UserEntities/AssignedEntitiesTable.tsx | 12 +++++----- 6 files changed, 29 insertions(+), 37 deletions(-) diff --git a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx index 18ab0a7d76c..f06fdaaa32d 100644 --- a/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx +++ b/packages/manager/src/features/IAM/Roles/RolesTable/RolesTable.tsx @@ -56,10 +56,8 @@ export const RolesTable = ({ roles = [] }: Props) => { const [selectedRows, setSelectedRows] = useState([]); const [isDrawerOpen, setIsDrawerOpen] = useState(false); - const { data: permissions } = usePermissions('account', [ - 'update_user_grants', - ]); - const canUpdateUserGrants = permissions?.update_user_grants; + const { data: permissions } = usePermissions('account', ['is_account_admin']); + const isAccountAdmin = permissions?.is_account_admin; const pagination = usePaginationV2({ currentRoute: '/iam/roles', @@ -201,12 +199,12 @@ export const RolesTable = ({ roles = [] }: Props) => {