Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/api-v4/.changeset/pr-13033-added-1761736259411.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/api-v4": Added
---

IAM Parent/Child: delegate permissions ([#13033](https://github.com/linode/manager/pull/13033))
7 changes: 7 additions & 0 deletions packages/api-v4/src/iam/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand All @@ -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'
Expand Down
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-13033-added-1761736284038.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Added
---

IAM Parent/Child: delegate permissions for child account ([#13033](https://github.com/linode/manager/pull/13033))
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -426,13 +431,17 @@ 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([]);
mockGetNotifications([]);
mockGetAccount(mockParentAccount);
mockGetProfile(mockParentProfile);
mockGetUser(mockParentUser);
mockGetChildAccounts([]);
mockGetPaymentMethods(paymentMethodFactory.buildList(1)).as(
'getPaymentMethods'
);
Expand Down Expand Up @@ -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([]);
Expand Down
37 changes: 37 additions & 0 deletions packages/manager/cypress/support/intercepts/iam.ts
Original file line number Diff line number Diff line change
@@ -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<null> => {
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<null> => {
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<null> => {
return cy.intercept(
'GET',
apiMatcher('iam/role-permissions'),
makeErrorResponse(errorMessage, statusCode)
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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: [] };
Expand Down Expand Up @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
);
Expand All @@ -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}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
},
Expand All @@ -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: () => {
Expand All @@ -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: () => {
Expand All @@ -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: () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,19 @@ 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 { 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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we still need to check if user has is_account_admin AND update_default_delegate_access, but I guess we can find out once api would be ready

? permissions?.update_default_delegate_access
: permissions?.is_account_admin;

const { data: defaultRolesData, isLoading: defaultRolesLoading } =
useGetDefaultDelegationAccessQuery({
enabled: isDefaultDelegationRolesForChildAccount,
Expand Down Expand Up @@ -405,10 +412,10 @@ export const AssignedRolesTable = () => {
<Grid sx={{ alignSelf: 'flex-start' }}>
<Button
buttonType="primary"
disabled={!permissions?.is_account_admin}
disabled={!permissionToCheck}
onClick={() => setIsAssignNewRoleDrawerOpen(true)}
tooltipText={
!permissions?.is_account_admin
!permissionToCheck
? 'You do not have permission to assign roles.'
: undefined
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,17 @@ interface Props {
export const NoAssignedRoles = (props: Props) => {
const { text, hasAssignNewRoleDrawer } = 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();

const permissionToCheck = isDefaultDelegationRolesForChildAccount
? permissions?.update_default_delegate_access
: permissions?.is_account_admin;

const [isAssignNewRoleDrawerOpen, setIsAssignNewRoleDrawerOpen] =
React.useState<boolean>(false);

Expand Down Expand Up @@ -47,10 +54,10 @@ export const NoAssignedRoles = (props: Props) => {
{hasAssignNewRoleDrawer && (
<Button
buttonType="primary"
disabled={!permissions?.is_account_admin}
disabled={!permissionToCheck}
onClick={() => setIsAssignNewRoleDrawerOpen(true)}
tooltipText={
!permissions?.is_account_admin
!permissionToCheck
? 'You do not have permission to assign roles.'
: undefined
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,13 @@ export const accountGrantsToPermissions = (
update_oauth_client: true,
delete_oauth_client: true,
reset_oauth_client_secret: true,
// delegation permissions
Copy link
Contributor Author

@aaleksee-akamai aaleksee-akamai Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added all delegate permissions (the link to the APINext permissions are listed in the ticket)

list_delegate_users: true,
list_all_child_accounts: true,
update_delegate_users: true,
list_user_delegate_accounts: true,
update_default_delegate_access: true,
view_child_account: true,
create_child_account_token: true,
} as Record<AccountAdmin, boolean>;
};
Loading