diff --git a/packages/api-v4/.changeset/pr-13033-added-1761736259411.md b/packages/api-v4/.changeset/pr-13033-added-1761736259411.md new file mode 100644 index 00000000000..e98818c632e --- /dev/null +++ b/packages/api-v4/.changeset/pr-13033-added-1761736259411.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +IAM Parent/Child: delegate permissions ([#13033](https://github.com/linode/manager/pull/13033)) diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 1d14079936a..7424c7b1d80 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -75,6 +75,7 @@ export type AccountAdmin = | 'answer_profile_security_questions' | 'cancel_account' | 'cancel_service_transfer' + | 'create_child_account_token' | 'create_profile_pat' | 'create_profile_ssh_key' | 'create_profile_tfa_secret' @@ -91,17 +92,22 @@ export type AccountAdmin = | 'is_account_admin' | 'list_account_agreements' | 'list_account_logins' + | 'list_all_child_accounts' | 'list_available_services' | 'list_default_firewalls' + | 'list_delegate_users' | 'list_enrolled_beta_programs' | 'list_service_transfers' + | 'list_user_delegate_accounts' | 'list_user_grants' | 'revoke_profile_app' | 'revoke_profile_device' | 'send_profile_phone_number_verification_code' | 'update_account' | 'update_account_settings' + | 'update_default_delegate_access' | 'update_default_firewalls' + | 'update_delegate_users' | 'update_profile' | 'update_profile_pat' | 'update_profile_ssh_key' @@ -112,6 +118,7 @@ export type AccountAdmin = | 'view_account' | 'view_account_login' | 'view_account_settings' + | 'view_child_account' | 'view_enrolled_beta_program' | 'view_network_usage' | 'view_profile_security_question' diff --git a/packages/manager/.changeset/pr-13033-added-1761736284038.md b/packages/manager/.changeset/pr-13033-added-1761736284038.md new file mode 100644 index 00000000000..2a57981d069 --- /dev/null +++ b/packages/manager/.changeset/pr-13033-added-1761736284038.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +IAM Parent/Child: delegate permissions for child account ([#13033](https://github.com/linode/manager/pull/13033)) diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts index e3d328b43b3..b92602d8b6b 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -16,12 +16,17 @@ import { mockGetChildAccounts, mockGetChildAccountsError, mockGetInvoices, + mockGetMaintenance, mockGetPaymentMethods, mockGetPayments, mockGetUser, } from 'support/intercepts/account'; import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events'; import { mockAllApiRequests } from 'support/intercepts/general'; +import { + mockGetRolePermissionsError, + mockGetUserAccountPermissionsError, +} from 'support/intercepts/iam'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetProfile, @@ -426,6 +431,9 @@ describe('Parent/Child account switching', () => { // We'll mitigate this by broadly mocking ALL API-v4 requests, then applying more specific mocks to the // individual requests as needed. mockAllApiRequests(); + mockGetRolePermissionsError('Not found', 404); + mockGetUserAccountPermissionsError('Not found', 404); + mockGetMaintenance([], []); mockGetLinodes([]); mockGetRegions([]); mockGetEvents([]); @@ -433,6 +441,7 @@ describe('Parent/Child account switching', () => { mockGetAccount(mockParentAccount); mockGetProfile(mockParentProfile); mockGetUser(mockParentUser); + mockGetChildAccounts([]); mockGetPaymentMethods(paymentMethodFactory.buildList(1)).as( 'getPaymentMethods' ); @@ -511,6 +520,8 @@ describe('Parent/Child account switching', () => { // We'll mitigate this by broadly mocking ALL API-v4 requests, then applying more specific mocks to the // individual requests as needed. mockAllApiRequests(); + mockGetRolePermissionsError('Not found', 404); + mockGetUserAccountPermissionsError('Not found', 404); mockGetLinodes([]); mockGetRegions([]); mockGetEvents([]); diff --git a/packages/manager/cypress/support/intercepts/iam.ts b/packages/manager/cypress/support/intercepts/iam.ts new file mode 100644 index 00000000000..8049507896d --- /dev/null +++ b/packages/manager/cypress/support/intercepts/iam.ts @@ -0,0 +1,37 @@ +import { makeErrorResponse } from 'support/util/errors'; +import { apiMatcher } from 'support/util/intercepts'; +import { makeResponse } from 'support/util/response'; + +import type { PermissionType } from '@linode/api-v4'; + +export const mockGetUserAccountPermissions = ( + userAccountPermissions: PermissionType[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('iam/users/*/permissions/account'), + makeResponse(userAccountPermissions) + ); +}; + +export const mockGetUserAccountPermissionsError = ( + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('iam/users/*/permissions/account'), + makeErrorResponse(errorMessage, statusCode) + ); +}; + +export const mockGetRolePermissionsError = ( + errorMessage: string = 'An unknown error occurred.', + statusCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('iam/role-permissions'), + makeErrorResponse(errorMessage, statusCode) + ); +}; diff --git a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx index 3f44e4b2ab1..0ffe95c13b5 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedEntitiesTable/AssignedEntitiesTable.tsx @@ -57,7 +57,10 @@ interface Props { export const AssignedEntitiesTable = ({ username }: Props) => { const theme = useTheme(); - const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { data: permissions } = usePermissions('account', [ + 'is_account_admin', + 'update_default_delegate_access', + ]); const { isDefaultDelegationRolesForChildAccount } = useIsDefaultDelegationRolesForChildAccount(); @@ -131,6 +134,10 @@ export const AssignedEntitiesTable = ({ username }: Props) => { ? delegateDefaultRolesLoading : assignedUserRolesLoading; + const permissionToCheck = isDefaultDelegationRolesForChildAccount + ? permissions?.update_default_delegate_access + : permissions?.is_account_admin; + const { filterableOptions, roles } = React.useMemo(() => { if (!assignedRoles || !entities) { return { filterableOptions: [], roles: [] }; @@ -219,22 +226,22 @@ export const AssignedEntitiesTable = ({ username }: Props) => { .map((el: EntitiesRole) => { const actions: Action[] = [ { - disabled: !permissions?.is_account_admin, + disabled: !permissionToCheck, onClick: () => { handleChangeRole(el, 'change-role-for-entity'); }, - title: 'Change Role ', - tooltip: !permissions?.is_account_admin + title: 'Change Role', + tooltip: !permissionToCheck ? 'You do not have permission to change this role.' : undefined, }, { - disabled: !permissions?.is_account_admin, + disabled: !permissionToCheck, onClick: () => { handleRemoveAssignment(el); }, title: 'Remove Assignment', - tooltip: !permissions?.is_account_admin + tooltip: !permissionToCheck ? 'You do not have permission to remove this assignment.' : undefined, }, diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.test.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.test.tsx index 82239999773..395e333b30a 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.test.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.test.tsx @@ -41,7 +41,10 @@ describe('AssignedRolesActionMenu', () => { handleUnassignRole={mockOnUnassignRole} handleUpdateEntities={mockOnUpdateEntities} handleViewEntities={mockOnViewEntities} - permissions={{ is_account_admin: true }} + permissions={{ + is_account_admin: true, + update_default_delegate_access: true, + }} role={mockAccountRole} /> ); @@ -63,7 +66,10 @@ describe('AssignedRolesActionMenu', () => { handleUnassignRole={mockOnUnassignRole} handleUpdateEntities={mockOnUpdateEntities} handleViewEntities={mockOnViewEntities} - permissions={{ is_account_admin: true }} + permissions={{ + is_account_admin: true, + update_default_delegate_access: true, + }} role={mockEntityRole} /> ); diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx index ec96565a575..9ddf1bc817e 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesActionMenu.tsx @@ -2,11 +2,15 @@ import React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { useIsDefaultDelegationRolesForChildAccount } from '../../hooks/useDelegationRole'; + 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'>; +type RolesActionsPermissions = PickPermissions< + 'is_account_admin' | 'update_default_delegate_access' +>; interface Props { handleChangeRole: (role: ExtendedRoleView) => void; handleUnassignRole: (role: ExtendedRoleView) => void; @@ -24,24 +28,31 @@ export const AssignedRolesActionMenu = ({ handleViewEntities, role, }: Props) => { + const { isDefaultDelegationRolesForChildAccount } = + useIsDefaultDelegationRolesForChildAccount(); + + const permissionToCheck = isDefaultDelegationRolesForChildAccount + ? permissions?.update_default_delegate_access + : permissions?.is_account_admin; + const accountMenu: Action[] = [ { - disabled: !permissions.is_account_admin, + disabled: !permissionToCheck, onClick: () => { handleChangeRole(role); }, title: 'Change Role', - tooltip: !permissions.is_account_admin + tooltip: !permissionToCheck ? 'You do not have permission to change this role.' : undefined, }, { - disabled: !permissions.is_account_admin, + disabled: !permissionToCheck, onClick: () => { handleUnassignRole(role); }, title: 'Unassign Role', - tooltip: !permissions.is_account_admin + tooltip: !permissionToCheck ? 'You do not have permission to unassign this role.' : undefined, }, @@ -53,8 +64,8 @@ export const AssignedRolesActionMenu = ({ title: 'View Entities', }, { - disabled: !permissions.is_account_admin, - tooltip: !permissions.is_account_admin + disabled: !permissionToCheck, + tooltip: !permissionToCheck ? 'You do not have permission to update this role.' : undefined, onClick: () => { @@ -63,8 +74,8 @@ export const AssignedRolesActionMenu = ({ title: 'Update List of Entities', }, { - disabled: !permissions.is_account_admin, - tooltip: !permissions.is_account_admin + disabled: !permissionToCheck, + tooltip: !permissionToCheck ? 'You do not have permission to change this role.' : undefined, onClick: () => { @@ -73,8 +84,8 @@ export const AssignedRolesActionMenu = ({ title: 'Change Role', }, { - disabled: !permissions.is_account_admin, - tooltip: !permissions.is_account_admin + disabled: !permissionToCheck, + tooltip: !permissionToCheck ? 'You do not have permission to unassign this role.' : undefined, onClick: () => { diff --git a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx index c071ff0803d..82da1535714 100644 --- a/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx +++ b/packages/manager/src/features/IAM/Shared/AssignedRolesTable/AssignedRolesTable.tsx @@ -78,12 +78,19 @@ export const AssignedRolesTable = () => { const [order, setOrder] = React.useState<'asc' | 'desc'>('asc'); const [orderBy, setOrderBy] = React.useState('name'); const [isInitialLoad, setIsInitialLoad] = React.useState(true); - const { data: permissions } = usePermissions('account', ['is_account_admin']); + const { data: permissions } = usePermissions('account', [ + 'is_account_admin', + 'update_default_delegate_access', + ]); // Determine if we're on the default roles view based on delegation role and path const { isDefaultDelegationRolesForChildAccount } = useIsDefaultDelegationRolesForChildAccount(); + const permissionToCheck = isDefaultDelegationRolesForChildAccount + ? permissions?.update_default_delegate_access + : permissions?.is_account_admin; + const { data: defaultRolesData, isLoading: defaultRolesLoading } = useGetDefaultDelegationAccessQuery({ enabled: isDefaultDelegationRolesForChildAccount, @@ -405,10 +412,10 @@ export const AssignedRolesTable = () => {