Skip to content

Commit

Permalink
ARTESCA-10989 - create SelectAccountIAMRole
Browse files Browse the repository at this point in the history
  • Loading branch information
MonPote committed Apr 29, 2024
1 parent 5b9195f commit 40801bb
Show file tree
Hide file tree
Showing 2 changed files with 296 additions and 2 deletions.
292 changes: 292 additions & 0 deletions src/react/ui-elements/SelectAccountIAMRole.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
import { Stack } from '@scality/core-ui';
import { Select } from '@scality/core-ui/dist/next';
import { IAM } from 'aws-sdk';
import { Bucket } from 'aws-sdk/clients/s3';
import { PropsWithChildren, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { MemoryRouter, Route, useHistory, useParams } from 'react-router-dom';
import DataServiceRoleProvider, {
useAssumedRole,
useSetAssumedRole,
} from '../DataServiceRoleProvider';
import { useIAMClient } from '../IAMProvider';
import { IMetricsAdapter } from '../next-architecture/adapters/metrics/IMetricsAdapter';
import { useListAccounts } from '../next-architecture/domain/business/accounts';
import { Account } from '../next-architecture/domain/entities/account';
import { LatestUsedCapacity } from '../next-architecture/domain/entities/metrics';
import {
AccessibleAccountsAdapterProvider,
useAccessibleAccountsAdapter,
} from '../next-architecture/ui/AccessibleAccountsAdapterProvider';
import { AccountsLocationsEndpointsAdapterProvider } from '../next-architecture/ui/AccountsLocationsEndpointsAdapterProvider';
import { getListRolesQuery } from '../queries';
import { regexArn } from '../utils/hooks';

class NoOppMetricsAdapter implements IMetricsAdapter {
async listBucketsLatestUsedCapacity(
buckets: Bucket[],
): Promise<Record<string, LatestUsedCapacity>> {
return {};
}
async listLocationsLatestUsedCapacity(
locationIds: string[],
): Promise<Record<string, LatestUsedCapacity>> {
return {};
}
async listAccountLocationsLatestUsedCapacity(
accountCanonicalId: string,
): Promise<Record<string, LatestUsedCapacity>> {
return {};
}
async listAccountsLatestUsedCapacity(
accountCanonicalIds: string[],
): Promise<Record<string, LatestUsedCapacity>> {
return {};
}
}

const filterRoles = (
accountName: string,
roles: IAM.Role[],
hideAccountRoles: { accountName: string; roleName: string }[],
) => {
return roles.filter(
(role) =>
!hideAccountRoles.find(
(hideRole) =>
hideRole.accountName === accountName &&
hideRole.roleName === role.RoleName,
),
);
};

/**
* DataServiceRoleProvider is using the the path to figure out what is the current account.
* In order to reuse this logic, we need to have a router and set DataServiceRoleProvider under
* the path /accounts/:accountName
* Without this INTERNAL_DEFAULT_ACCOUNT_NAME_FOR_INITIALIZATION, it won't render.
*
* We assume the user won't have an account with this name.
*/
const INTERNAL_DEFAULT_ACCOUNT_NAME_FOR_INITIALIZATION =
'__INTERNAL_DEFAULT_ACCOUNT_NAME_FOR_INITIALIZATION__';

const AssumeDefaultIAMRole = ({
defaultValue,
}: Pick<SelectAccountIAMRoleWithAccountProps, 'defaultValue'>) => {
const accessibleAccountsAdapter = useAccessibleAccountsAdapter();
const metricsAdapter = new NoOppMetricsAdapter();
const accounts = useListAccounts({
accessibleAccountsAdapter,
metricsAdapter,
});
const history = useHistory();
const setAssumeRole = useSetAssumedRole();

const isInternalDefaultAccountSelected =
history.location.pathname ===
'/accounts/' + INTERNAL_DEFAULT_ACCOUNT_NAME_FOR_INITIALIZATION;

if (
accounts.accounts.status === 'success' &&
defaultValue &&
isInternalDefaultAccountSelected
) {
const acc = accounts.accounts.value.find(
(acc) => acc.name === defaultValue?.accountName,
);

/**
* This set state will trigger a warning because it's not in a useEffect.
* This is fine because the set state is under an if and it should not be called too many times.
* The only time it could break is if for some reason the user use an account that is named like
* INTERNAL_DEFAULT_ACCOUNT_NAME_FOR_INITIALIZATION and use the component with a defaultValue.
*/
setAssumeRole({
roleArn: acc?.preferredAssumableRoleArn ?? '',
});
history.replace('/accounts/' + defaultValue?.accountName);
}

return <></>;
};

const InternalProvider = ({
children,
defaultValue,
}: PropsWithChildren<
Pick<SelectAccountIAMRoleWithAccountProps, 'defaultValue'>
>) => {
return (
<MemoryRouter
initialEntries={[
`/accounts/${INTERNAL_DEFAULT_ACCOUNT_NAME_FOR_INITIALIZATION}`,
]}
>
<Route path="/accounts/:accountName">
<DataServiceRoleProvider DoNotChangePropsWithRedux={false}>
<AccountsLocationsEndpointsAdapterProvider>
<AccessibleAccountsAdapterProvider
DoNotChangePropsWithEventDispatcher={false}
>
<>
<AssumeDefaultIAMRole defaultValue={defaultValue} />
{children}
</>
</AccessibleAccountsAdapterProvider>
</AccountsLocationsEndpointsAdapterProvider>
</DataServiceRoleProvider>
</Route>
</MemoryRouter>
);
};

type SelectAccountIAMRoleProps = {
onChange: (account: Account, role: IAM.Role) => void;
defaultValue?: { accountName: string; roleName: string };
hideAccountRoles?: { accountName: string; roleName: string }[];
};

type SelectAccountIAMRoleWithAccountProps = SelectAccountIAMRoleProps & {
accounts: Account[];
};

const SelectAccountIAMRoleWithAccount = (
props: SelectAccountIAMRoleWithAccountProps,
) => {
const history = useHistory();
const IAMClient = useIAMClient();
const setAssumedRole = useSetAssumedRole();
const { accounts, defaultValue, hideAccountRoles, onChange } = props;
const defaultAccountName = useParams<{ accountName: string }>().accountName;
const defaultAccount =
accounts.find((account) => account.name === defaultAccountName) ?? null;
const [account, setAccount] = useState<Account | null>(defaultAccount);
const [role, setRole] = useState<IAM.Role | null>(null);
const assumedRole = useAssumedRole();

const accountName = account ? account.name : '';
const rolesQuery = getListRolesQuery(accountName, IAMClient);
const queryClient = useQueryClient();

const assumedRoleAccountId = regexArn.exec(assumedRole?.AssumedRoleUser?.Arn)
?.groups?.['account_id'];
const selectedAccountId = regexArn.exec(account?.preferredAssumableRoleArn)
?.groups?.['account_id'];

/**
* When we change account, it will take some time to assume the role for the new account.
* We need this check to make sure we don't show the roles for the old account.
*/
const assumedRoleAccountMatchSelectedAccount =
assumedRoleAccountId === selectedAccountId;

const listRolesQuery = {
...rolesQuery,
enabled:
!!IAMClient &&
!!IAMClient.client &&
accountName !== '' &&
assumedRoleAccountMatchSelectedAccount,
};
const roleQueryData = useQuery(listRolesQuery);

const roles = filterRoles(
accountName,
roleQueryData?.data?.Roles ?? [],
hideAccountRoles,
);

const isDefaultAccountSelected = account?.name === defaultValue?.accountName;
const defaultRole = isDefaultAccountSelected ? defaultValue?.roleName : null;

return (
<Stack>
<Select
id="select-account"
value={account?.name ?? defaultValue?.accountName}
onChange={(accountName) => {
const selectedAccount = accounts.find(
(account) => account.name === accountName,
);

setAssumedRole({
roleArn: selectedAccount.preferredAssumableRoleArn,
});
history.push(`/accounts/${accountName}`);

setAccount(selectedAccount);
setRole(null);
queryClient.invalidateQueries(rolesQuery.queryKey);
}}
size="1/2"
placeholder="Select Account"
>
{accounts.map((account) => (
<Select.Option key={`${account.name}`} value={account.name}>
{account.name}
</Select.Option>
))}
</Select>

{roles.length > 0 ? (
<Select
id="select-account-role"
value={role?.RoleName ?? defaultRole}
onChange={(roleName) => {
const selectedRole = roles.find(
(role) => role.RoleName === roleName,
);
onChange(account, selectedRole);
setRole(selectedRole);
}}
size="2/3"
placeholder="Select Role"
>
{roles.map((role) => (
<Select.Option key={`${role.RoleName}`} value={role.RoleName}>
{role.RoleName}
</Select.Option>
))}
</Select>
) : null}
</Stack>
);
};

const defaultOnChange = () => ({});
export const _SelectAccountIAMRole = (props: SelectAccountIAMRoleProps) => {
const {
onChange = defaultOnChange,
hideAccountRoles = [],
defaultValue,
} = props;

const accessibleAccountsAdapter = useAccessibleAccountsAdapter();
const metricsAdapter = new NoOppMetricsAdapter();
const accounts = useListAccounts({
accessibleAccountsAdapter,
metricsAdapter,
});

if (accounts.accounts.status === 'success') {
return (
<SelectAccountIAMRoleWithAccount
accounts={accounts.accounts.value}
defaultValue={defaultValue}
hideAccountRoles={hideAccountRoles}
onChange={onChange}
/>
);
} else {
return <div>Loading accounts...</div>;
}
};

export const SelectAccountIAMRole = (props: SelectAccountIAMRoleProps) => {
return (
<InternalProvider defaultValue={props.defaultValue}>
<_SelectAccountIAMRole {...props} />
</InternalProvider>
);
};
6 changes: 4 additions & 2 deletions src/react/utils/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,10 @@ export function useQueryWithUnmountSupport<
});
return query;
}

// arn:aws:sts::142222634614:assumed-role/storage-manager-role/ui-9160673b-2c2a-4a6f-a1ef-a3cb6ce25d7f
// arn:aws:iam::142222634614:role/scality-internal/storage-manager-role
export const regexArn =
/arn:aws:iam::(?<account_id>\d{12}):(?<resource_type>role|policy)\/(?<path>(?:[^/]*\/)*)(?<name>[^/]+)$/;
/arn:aws:(?:iam|sts)::(?<account_id>\d{12}):(?<resource_type>role|policy|assumed-role)\/(?<path>(?:[^/]*\/)*)(?<name>[^/]+)$/;

export const STORAGE_MANAGER_ROLE = 'storage-manager-role';
export const STORAGE_ACCOUNT_OWNER_ROLE = 'storage-account-owner-role';
Expand Down Expand Up @@ -233,6 +234,7 @@ export const useAccounts = (
},
(data) => data.Accounts,
);

const uniqueAccountsWithRoles = Object.values(
data?.reduce(
(agg, current) => ({
Expand Down

0 comments on commit 40801bb

Please sign in to comment.