Skip to content

Commit

Permalink
Organizations: Allow cross-account access (#8260)
Browse files Browse the repository at this point in the history
  • Loading branch information
viren-nadkarni authored Oct 28, 2024
1 parent ecbe1ca commit 103f57a
Show file tree
Hide file tree
Showing 3 changed files with 100 additions and 12 deletions.
10 changes: 10 additions & 0 deletions moto/organizations/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 45 additions & 6 deletions moto/organizations/models.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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
from moto.core.exceptions import RESTError
from moto.core.utils import unix_time, utcnow
from moto.organizations import utils
from moto.organizations.exceptions import (
AccountAlreadyClosedException,
AccountAlreadyRegisteredException,
AccountNotFoundException,
AccountNotRegisteredException,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"]:
Expand Down Expand Up @@ -523,13 +535,18 @@ 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:
for account in self.accounts:
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:
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 45 additions & 6 deletions tests/test_organizations/test_organizations_boto3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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(
Expand All @@ -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():
Expand Down

0 comments on commit 103f57a

Please sign in to comment.