diff --git a/moto/organizations/exceptions.py b/moto/organizations/exceptions.py index 4a08438f93eb..10fb8ecbedb3 100644 --- a/moto/organizations/exceptions.py +++ b/moto/organizations/exceptions.py @@ -11,6 +11,16 @@ def __init__(self) -> None: ) +class AccountAlreadyClosedException(JsonRESTError): + code = 400 + + def __init__(self) -> None: + super().__init__( + "AccountAlreadyClosedException", + "The provided account is already closed.", + ) + + class AccountNotRegisteredException(JsonRESTError): code = 400 diff --git a/moto/organizations/models.py b/moto/organizations/models.py index 2f0f03b187c9..d84f0ca2e1e9 100644 --- a/moto/organizations/models.py +++ b/moto/organizations/models.py @@ -1,6 +1,6 @@ import json import re -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from moto.core.base_backend import BackendDict, BaseBackend from moto.core.common_models import BaseModel @@ -8,6 +8,7 @@ from moto.core.utils import unix_time, utcnow from moto.organizations import utils from moto.organizations.exceptions import ( + AccountAlreadyClosedException, AccountAlreadyRegisteredException, AccountNotFoundException, AccountNotRegisteredException, @@ -149,6 +150,8 @@ def describe(self) -> Dict[str, Any]: } def close(self) -> None: + if self.status == "SUSPENDED": + raise AccountAlreadyClosedException # TODO: The CloseAccount spec allows the account to pass through a # "PENDING_CLOSURE" state before reaching the SUSPENDED state. self.status = "SUSPENDED" @@ -451,9 +454,18 @@ def create_organization(self, region: str, **kwargs: Any) -> Dict[str, Any]: return self.org.describe() def describe_organization(self) -> Dict[str, Any]: - if not self.org: - raise AWSOrganizationsNotInUseException - return self.org.describe() + if self.org: + # This is a master account + return self.org.describe() + + if self.account_id in organizations_backends.master_accounts: + # This is a member account + master_account_id, partition = organizations_backends.master_accounts[ + self.account_id + ] + return organizations_backends[master_account_id][partition].org.describe() # type: ignore[union-attr] + + raise AWSOrganizationsNotInUseException def delete_organization(self) -> None: if [account for account in self.accounts if account.name != "master"]: @@ -523,6 +535,10 @@ def create_account(self, **kwargs: Any) -> Dict[str, Any]: new_account = FakeAccount(self.org, **kwargs) # type: ignore self.accounts.append(new_account) self.attach_policy(PolicyId=utils.DEFAULT_POLICY_ID, TargetId=new_account.id) + organizations_backends.master_accounts[new_account.id] = ( + self.account_id, + self.partition, + ) return new_account.create_account_status def close_account(self, **kwargs: Any) -> None: @@ -530,6 +546,7 @@ def close_account(self, **kwargs: Any) -> None: if account.id == kwargs["AccountId"]: account.close() return + organizations_backends.master_accounts.pop(kwargs["AccountId"], None) raise AccountNotFoundException def get_account_by_id(self, account_id: str) -> FakeAccount: @@ -977,13 +994,35 @@ def detach_policy(self, **kwargs: str) -> None: raise InvalidInputException("You specified an invalid value.") def remove_account_from_organization(self, **kwargs: str) -> None: - account = self.get_account_by_id(kwargs["AccountId"]) + account_id = kwargs["AccountId"] + if account_id not in organizations_backends.master_accounts: + raise AWSOrganizationsNotInUseException + organizations_backends.master_accounts.pop(account_id, None) + account = self.get_account_by_id(account_id) for policy in account.attached_policies: policy.attachments.remove(account) self.accounts.remove(account) -organizations_backends = BackendDict( +class OrganizationsBackendDict(BackendDict[OrganizationsBackend]): + """ + Specialised to keep track of master accounts. + """ + + def __init__( + self, + backend: Any, + service_name: str, + use_boto3_regions: bool = True, + additional_regions: Optional[List[str]] = None, + ): + super().__init__(backend, service_name, use_boto3_regions, additional_regions) + + # Maps member account IDs to the (master account ID, partition) which owns the organisation + self.master_accounts: Dict[str, Tuple[str, str]] = {} + + +organizations_backends = OrganizationsBackendDict( OrganizationsBackend, "organizations", use_boto3_regions=False, diff --git a/tests/test_organizations/test_organizations_boto3.py b/tests/test_organizations/test_organizations_boto3.py index 5c1ee7d18cb1..81d83329709b 100644 --- a/tests/test_organizations/test_organizations_boto3.py +++ b/tests/test_organizations/test_organizations_boto3.py @@ -31,6 +31,11 @@ validate_service_control_policy, ) +# Accounts +mockname = "mock-account" +mockdomain = "moto-example.org" +mockemail = "@".join([mockname, mockdomain]) + @mock_aws @pytest.mark.parametrize( @@ -112,11 +117,26 @@ def test_create_account_creates_custom_role(): @mock_aws def test_describe_organization(): + if not settings.TEST_DECORATOR_MODE: + raise SkipTest("Involves changing account using env variable") + client = boto3.client("organizations", region_name="us-east-1") client.create_organization(FeatureSet="ALL") response = client.describe_organization() validate_organization(response) + # Ensure member accounts can also describe the organisation from any region + account_id = client.create_account(AccountName=mockname, Email=mockemail)[ + "CreateAccountStatus" + ]["AccountId"] + + for region_name in ["ap-south-1", "eu-west-1"]: + with mock.patch.dict(os.environ, {"MOTO_ACCOUNT_ID": account_id}): + response = boto3.client( + "organizations", region_name=region_name + ).describe_organization() + validate_organization(response) + @mock_aws def test_describe_organization_exception(): @@ -250,12 +270,6 @@ def test_list_organizational_units_for_parent_exception(): assert "ParentNotFoundException" in ex.response["Error"]["Message"] -# Accounts -mockname = "mock-account" -mockdomain = "moto-example.org" -mockemail = "@".join([mockname, mockdomain]) - - @mock_aws def test_create_account(): client = boto3.client("organizations", region_name="us-east-1") @@ -297,6 +311,14 @@ def test_close_account_puts_account_in_suspended_status(): account = client.describe_account(AccountId=created_account_id)["Account"] assert account["Status"] == "SUSPENDED" + with pytest.raises(ClientError) as exc: + client.close_account(AccountId=created_account_id) + assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert "AccountAlreadyClosedException" in exc.value.response["Error"]["Code"] + assert exc.value.response["Error"]["Message"] == ( + "The provided account is already closed." + ) + @mock_aws def test_close_account_id_not_in_org_raises_exception(): @@ -607,6 +629,9 @@ def test_get_paginated_list_create_account_status(): @mock_aws def test_remove_account_from_organization(): + if not settings.TEST_DECORATOR_MODE: + raise SkipTest("Involves changing account using env variable") + client = boto3.client("organizations", region_name="us-east-1") _ = client.create_organization(FeatureSet="ALL")["Organization"] create_account_status = client.create_account( @@ -629,6 +654,20 @@ def created_account_exists(accounts): assert len(accounts) == 1 assert not created_account_exists(accounts) + # After the account is removed from an organisation, calling DescribeOrganization from the removed account should raise + with mock.patch.dict(os.environ, {"MOTO_ACCOUNT_ID": account_id}): + with pytest.raises(ClientError) as exc: + client.describe_organization() + assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert "AWSOrganizationsNotInUse" in exc.value.response["Error"]["Code"] + + # Attempting to remove invalid account must raise + bad_account_id = "010101010101" + with pytest.raises(ClientError) as exc: + client.remove_account_from_organization(AccountId=bad_account_id) + assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400 + assert "AWSOrganizationsNotInUse" in exc.value.response["Error"]["Code"] + @mock_aws def test_delete_organization_with_existing_account():