diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts
index 1da75c56215..fa36b3e8bf5 100644
--- a/packages/api-v4/src/iam/types.ts
+++ b/packages/api-v4/src/iam/types.ts
@@ -125,6 +125,7 @@ export type AccountAdmin =
| 'view_account_login'
| 'view_account_settings'
| 'view_child_account'
+ | 'view_default_delegate_access'
| 'view_enrolled_beta_program'
| 'view_lock'
| 'view_network_usage'
diff --git a/packages/manager/.changeset/pr-13409-added-1771405985553.md b/packages/manager/.changeset/pr-13409-added-1771405985553.md
new file mode 100644
index 00000000000..1c4b57f817a
--- /dev/null
+++ b/packages/manager/.changeset/pr-13409-added-1771405985553.md
@@ -0,0 +1,5 @@
+---
+"@linode/manager": Added
+---
+
+IAM Parent/Child: add permissions to UI ([#13409](https://github.com/linode/manager/pull/13409))
diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx
index 6827a0013b3..b98a0b6c970 100644
--- a/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx
+++ b/packages/manager/src/features/IAM/Delegations/AccountDelegations.test.tsx
@@ -13,8 +13,17 @@ const mocks = vi.hoisted(() => ({
mockUseGetChildAccountsQuery: vi.fn(),
useParams: vi.fn().mockReturnValue({}),
useSearch: vi.fn().mockReturnValue({}),
+ usePermissions: vi.fn().mockReturnValue({}),
}));
+vi.mock('src/features/IAM/hooks/usePermissions', async () => {
+ const actual = await vi.importActual('src/features/IAM/hooks/usePermissions');
+ return {
+ ...actual,
+ usePermissions: mocks.usePermissions,
+ };
+});
+
vi.mock('@tanstack/react-router', async () => {
const actual = await vi.importActual('@tanstack/react-router');
return {
@@ -53,6 +62,10 @@ describe('AccountDelegations', () => {
data: { data: mockDelegations, results: mockDelegations.length },
isLoading: false,
});
+ mocks.usePermissions.mockReturnValue({
+ data: { list_all_child_accounts: true },
+ isLoading: false,
+ });
});
it('should render the delegations table with data', async () => {
@@ -93,4 +106,23 @@ describe('AccountDelegations', () => {
expect(emptyElement).toBeInTheDocument();
});
});
+
+ it('should not render if user does not have permissions', () => {
+ mocks.usePermissions.mockReturnValue({
+ data: {
+ list_all_child_accounts: false,
+ },
+ isLoading: false,
+ });
+
+ renderWithTheme(, {
+ flags: { iamDelegation: { enabled: true }, iam: { enabled: true } },
+ initialRoute: '/iam',
+ });
+ expect(
+ screen.queryByText(
+ 'You do not have permission to view account delegations.'
+ )
+ ).toBeVisible();
+ });
});
diff --git a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx
index 2393e8649f3..ca54e98572f 100644
--- a/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx
+++ b/packages/manager/src/features/IAM/Delegations/AccountDelegations.tsx
@@ -1,5 +1,5 @@
import { useGetChildAccountsQuery } from '@linode/queries';
-import { CircleProgress, Paper, Stack } from '@linode/ui';
+import { CircleProgress, Notice, Paper, Stack } from '@linode/ui';
import { useMediaQuery, useTheme } from '@mui/material';
import { useNavigate, useSearch } from '@tanstack/react-router';
import React from 'react';
@@ -10,6 +10,7 @@ import { useOrderV2 } from 'src/hooks/useOrderV2';
import { usePaginationV2 } from 'src/hooks/usePaginationV2';
import { useIsIAMDelegationEnabled } from '../hooks/useIsIAMEnabled';
+import { usePermissions } from '../hooks/usePermissions';
import { AccountDelegationsTable } from './AccountDelegationsTable';
const DELEGATIONS_ROUTE = '/iam/delegations';
@@ -17,6 +18,10 @@ const DELEGATIONS_ROUTE = '/iam/delegations';
export const AccountDelegations = () => {
const navigate = useNavigate();
const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
+ const { data: permissions, isLoading: isPermissionsLoading } = usePermissions(
+ 'account',
+ ['list_all_child_accounts']
+ );
const { company } = useSearch({
from: '/iam',
@@ -78,12 +83,22 @@ export const AccountDelegations = () => {
});
};
- if (isLoading) {
+ if (isLoading || isPermissionsLoading) {
return ;
}
+
+ if (!permissions?.list_all_child_accounts) {
+ return (
+
+ You do not have permission to view account delegations.
+
+ );
+ }
+
if (!isIAMDelegationEnabled) {
return null;
}
+
return (
({ marginTop: theme.tokens.spacing.S16 })}>
({
useIsDefaultDelegationRolesForChildAccount: vi
.fn()
.mockReturnValue({ isDefaultDelegationRolesForChildAccount: true }),
+ usePermissions: vi.fn().mockReturnValue({}),
}));
vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({
@@ -41,6 +42,14 @@ vi.mock('src/queries/entities/entities', async () => {
};
});
+vi.mock('src/features/IAM/hooks/usePermissions', async () => {
+ const actual = await vi.importActual('src/features/IAM/hooks/usePermissions');
+ return {
+ ...actual,
+ usePermissions: queryMocks.usePermissions,
+ };
+});
+
vi.mock('@tanstack/react-router', async () => {
const actual = await vi.importActual('@tanstack/react-router');
return {
@@ -51,6 +60,14 @@ vi.mock('@tanstack/react-router', async () => {
});
describe('DefaultEntityAccess', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ queryMocks.usePermissions.mockReturnValue({
+ data: { view_default_delegate_access: true },
+ isLoading: false,
+ });
+ });
it('should render', async () => {
queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({
data: {
@@ -92,4 +109,21 @@ describe('DefaultEntityAccess', () => {
renderWithTheme();
expect(screen.getByText(ERROR_STATE_TEXT)).toBeVisible();
});
+
+ it('should not render if user does not have permissions', () => {
+ queryMocks.usePermissions.mockReturnValue({
+ data: {
+ view_default_delegate_access: false,
+ },
+ isLoading: false,
+ });
+
+ renderWithTheme();
+
+ expect(
+ screen.queryByText(
+ 'You do not have permission to view default entity access for delegate users.'
+ )
+ ).toBeVisible();
+ });
});
diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx
index 633c1b3d914..61552a9bf45 100644
--- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx
+++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultEntityAccess.tsx
@@ -2,12 +2,14 @@ import { useGetDefaultDelegationAccessQuery } from '@linode/queries';
import {
CircleProgress,
ErrorState,
+ Notice,
Paper,
Stack,
Typography,
} from '@linode/ui';
import * as React from 'react';
+import { usePermissions } from '../../hooks/usePermissions';
import { AssignedEntitiesTable } from '../../Shared/AssignedEntitiesTable/AssignedEntitiesTable';
import {
ERROR_STATE_TEXT,
@@ -16,20 +18,35 @@ import {
import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles';
export const DefaultEntityAccess = () => {
+ const { data: permissions, isLoading: isPermissionsLoading } = usePermissions(
+ 'account',
+ ['view_default_delegate_access']
+ );
const {
data: defaultAccess,
isLoading: defaultAccessLoading,
error,
- } = useGetDefaultDelegationAccessQuery({ enabled: true });
+ } = useGetDefaultDelegationAccessQuery({
+ enabled: permissions?.view_default_delegate_access,
+ });
const hasAssignedEntities = defaultAccess
? defaultAccess.entity_access.length > 0
: false;
- if (defaultAccessLoading) {
+ if (defaultAccessLoading || isPermissionsLoading) {
return ;
}
+ if (!permissions?.view_default_delegate_access) {
+ return (
+
+ You do not have permission to view default entity access for delegate
+ users.
+
+ );
+ }
+
if (error) {
return ;
}
diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx
index b27283a6393..9c560567186 100644
--- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx
+++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.test.tsx
@@ -17,6 +17,7 @@ const queryMocks = vi.hoisted(() => ({
useIsDefaultDelegationRolesForChildAccount: vi
.fn()
.mockReturnValue({ isDefaultDelegationRolesForChildAccount: true }),
+ usePermissions: vi.fn().mockReturnValue({}),
}));
vi.mock('@tanstack/react-router', async () => {
@@ -35,11 +36,28 @@ vi.mock('@linode/queries', async () => {
queryMocks.useGetDefaultDelegationAccessQuery,
};
});
+
+vi.mock('src/features/IAM/hooks/usePermissions', async () => {
+ const actual = await vi.importActual('src/features/IAM/hooks/usePermissions');
+ return {
+ ...actual,
+ usePermissions: queryMocks.usePermissions,
+ };
+});
+
vi.mock('src/features/IAM/hooks/useDelegationRole', () => ({
useIsDefaultDelegationRolesForChildAccount:
queryMocks.useIsDefaultDelegationRolesForChildAccount,
}));
describe('DefaultRoles', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ queryMocks.usePermissions.mockReturnValue({
+ data: { view_default_delegate_access: true },
+ isLoading: false,
+ });
+ });
it('should render', async () => {
queryMocks.useGetDefaultDelegationAccessQuery.mockReturnValue({
data: {
@@ -84,4 +102,21 @@ describe('DefaultRoles', () => {
renderWithTheme();
expect(screen.getByText(ERROR_STATE_TEXT)).toBeVisible();
});
+
+ it('should not render if user does not have permissions', () => {
+ queryMocks.usePermissions.mockReturnValue({
+ data: {
+ view_default_delegate_access: false,
+ },
+ isLoading: false,
+ });
+
+ renderWithTheme();
+
+ expect(
+ screen.queryByText(
+ 'You do not have permission to view default roles for delegate users.'
+ )
+ ).toBeVisible();
+ });
});
diff --git a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx
index 7c979c02d71..11e60aba199 100644
--- a/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx
+++ b/packages/manager/src/features/IAM/Roles/Defaults/DefaultRoles.tsx
@@ -1,7 +1,14 @@
import { useGetDefaultDelegationAccessQuery } from '@linode/queries';
-import { CircleProgress, ErrorState, Paper, Typography } from '@linode/ui';
+import {
+ CircleProgress,
+ ErrorState,
+ Notice,
+ Paper,
+ Typography,
+} from '@linode/ui';
import * as React from 'react';
+import { usePermissions } from '../../hooks/usePermissions';
import { AssignedRolesTable } from '../../Shared/AssignedRolesTable/AssignedRolesTable';
import {
ERROR_STATE_TEXT,
@@ -10,20 +17,35 @@ import {
import { NoAssignedRoles } from '../../Shared/NoAssignedRoles/NoAssignedRoles';
export const DefaultRoles = () => {
+ const { data: permissions, isLoading: isPermissionsLoading } = usePermissions(
+ 'account',
+ ['view_default_delegate_access']
+ );
const {
data: defaultRolesData,
isLoading: defaultRolesLoading,
error,
- } = useGetDefaultDelegationAccessQuery({ enabled: true });
+ } = useGetDefaultDelegationAccessQuery({
+ enabled: permissions?.view_default_delegate_access,
+ });
+
const hasAssignedRoles = defaultRolesData
? defaultRolesData.account_access.length > 0 ||
defaultRolesData.entity_access.length > 0
: false;
- if (defaultRolesLoading) {
+ if (defaultRolesLoading || isPermissionsLoading) {
return ;
}
+ if (!permissions?.view_default_delegate_access) {
+ return (
+
+ You do not have permission to view default roles for delegate users.
+
+ );
+ }
+
if (error) {
return ;
}
diff --git a/packages/manager/src/features/IAM/Roles/Roles.tsx b/packages/manager/src/features/IAM/Roles/Roles.tsx
index 45aa59d204e..d14073ebb3a 100644
--- a/packages/manager/src/features/IAM/Roles/Roles.tsx
+++ b/packages/manager/src/features/IAM/Roles/Roles.tsx
@@ -19,7 +19,8 @@ export const RolesLanding = () => {
permissions?.list_role_permissions
);
const { isIAMDelegationEnabled } = useIsIAMDelegationEnabled();
- const { isChildUserType, isProfileLoading } = useDelegationRole();
+ const { isChildUserType, isProfileLoading, isDelegateUserType } =
+ useDelegationRole();
const { roles } = React.useMemo(() => {
if (!accountRoles) {
@@ -41,7 +42,9 @@ export const RolesLanding = () => {
return (
<>
- {isChildUserType && isIAMDelegationEnabled && }
+ {(isChildUserType || isDelegateUserType) && isIAMDelegationEnabled && (
+
+ )}
({ marginTop: theme.tokens.spacing.S16 })}>
Roles
diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx
index 8b729db6fcd..9a34910cc71 100644
--- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx
+++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.test.tsx
@@ -14,6 +14,7 @@ const queryMocks = vi.hoisted(() => ({
useNavigate: vi.fn().mockReturnValue(vi.fn()),
useGetDelegatedChildAccountsForUserQuery: vi.fn().mockReturnValue({}),
useAccountRoles: vi.fn().mockReturnValue({}),
+ usePermissions: vi.fn().mockReturnValue({}),
}));
vi.mock('@linode/queries', async () => {
@@ -26,6 +27,14 @@ vi.mock('@linode/queries', async () => {
};
});
+vi.mock('src/features/IAM/hooks/usePermissions', async () => {
+ const actual = await vi.importActual('src/features/IAM/hooks/usePermissions');
+ return {
+ ...actual,
+ usePermissions: queryMocks.usePermissions,
+ };
+});
+
vi.mock('@tanstack/react-router', async () => {
const actual = await vi.importActual('@tanstack/react-router');
return {
@@ -48,6 +57,10 @@ describe('UserDelegations', () => {
data: accountRolesFactory.build(),
isLoading: false,
});
+ queryMocks.usePermissions.mockReturnValue({
+ data: { list_user_delegate_accounts: true },
+ isLoading: false,
+ });
});
it('should display no roles text if no roles are assigned to user', async () => {
@@ -86,4 +99,26 @@ describe('UserDelegations', () => {
expect(screen.getByText('Account Delegations')).toBeVisible();
});
+
+ it('should not render if user does not have permissions', () => {
+ queryMocks.usePermissions.mockReturnValue({
+ data: {
+ list_user_delegate_accounts: false,
+ },
+ });
+
+ renderWithTheme(, {
+ flags: {
+ iam: { enabled: true },
+ iamDelegation: {
+ enabled: true,
+ },
+ },
+ });
+ expect(
+ screen.queryByText(
+ `You do not have permission to view this user's account delegations.`
+ )
+ ).toBeVisible();
+ });
});
diff --git a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx
index bca7ac4d982..cc0cf34ba74 100644
--- a/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx
+++ b/packages/manager/src/features/IAM/Users/UserDelegations/UserDelegations.tsx
@@ -1,10 +1,11 @@
import { useGetDelegatedChildAccountsForUserQuery } from '@linode/queries';
-import { CircleProgress, ErrorState } from '@linode/ui';
+import { CircleProgress, ErrorState, Notice } 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 {
ERROR_STATE_TEXT,
NO_ACCOUNT_DELEGATIONS_TEXT,
@@ -15,22 +16,36 @@ import { UserDelegationsTable } from './UserDelegationsTable';
export const UserDelegations = () => {
const { username } = useParams({ from: '/iam/users/$username' });
+ const { data: permissions, isLoading: isPermissionsLoading } = usePermissions(
+ 'account',
+ ['list_user_delegate_accounts']
+ );
+
const {
data: allDelegatedChildAccounts,
isLoading,
error,
} = useGetDelegatedChildAccountsForUserQuery({
username,
+ enabled: permissions?.list_user_delegate_accounts,
});
const hasDelegatedChildAccounts = allDelegatedChildAccounts
? allDelegatedChildAccounts.data.length > 0
: false;
- if (isLoading) {
+ if (isLoading || isPermissionsLoading) {
return ;
}
+ if (!permissions?.list_user_delegate_accounts) {
+ return (
+
+ You do not have permission to view this user's account delegations.
+
+ );
+ }
+
if (error) {
return ;
}
diff --git a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx
index 0b928d251a1..6e4f452a119 100644
--- a/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx
+++ b/packages/manager/src/features/IAM/Users/UsersTable/Users.tsx
@@ -202,6 +202,7 @@ export const UsersLanding = () => {
/>
{isChildOrDelegateWithDelegationEnabled && (