Repository: getmoto/moto
Pull request #8295 (2024-11-06 11:51:36)

Organizations: Extend validation and errors
This PR brings some improvements to organizations by implementing validations for running certain operations.

- `CreateOrganization` can only be called if the calling account does not already own an org
- `DeleteOrganization` can only be called if the calling account owns an org
- `CreateAccount` and `CloseAccount` can only be called if the calling account owns the org

Related to https://github.com/getmoto/moto/pull/8260


Patch:
diff --git a/moto/organizations/exceptions.py b/moto/organizations/exceptions.py
index 10fb8ecbe..79aa44674 100644
--- a/moto/organizations/exceptions.py
+++ b/moto/organizations/exceptions.py
@@ -40,6 +40,16 @@ class AccountNotFoundException(JsonRESTError):
         )
 
 
+class AlreadyInOrganizationException(JsonRESTError):
+    code = 400
+
+    def __init__(self) -> None:
+        super().__init__(
+            "AlreadyInOrganizationException",
+            "The provided account is already a member of an organization.",
+        )
+
+
 class AWSOrganizationsNotInUseException(JsonRESTError):
     code = 400
 
@@ -83,6 +93,16 @@ class DuplicatePolicyException(JsonRESTError):
         )
 
 
+class OrganizationNotEmptyException(JsonRESTError):
+    code = 400
+
+    def __init__(self) -> None:
+        super().__init__(
+            "OrganizationNotEmptyException",
+            "To delete an organization you must first remove all member accounts (except the master).",
+        )
+
+
 class PolicyTypeAlreadyEnabledException(JsonRESTError):
     code = 400
 
diff --git a/moto/organizations/models.py b/moto/organizations/models.py
index d84f0ca2e..dba3eef7c 100644
--- a/moto/organizations/models.py
+++ b/moto/organizations/models.py
@@ -12,11 +12,13 @@ from moto.organizations.exceptions import (
     AccountAlreadyRegisteredException,
     AccountNotFoundException,
     AccountNotRegisteredException,
+    AlreadyInOrganizationException,
     AWSOrganizationsNotInUseException,
     ConstraintViolationException,
     DuplicateOrganizationalUnitException,
     DuplicatePolicyException,
     InvalidInputException,
+    OrganizationNotEmptyException,
     PolicyNotFoundException,
     PolicyTypeAlreadyEnabledException,
     PolicyTypeNotEnabledException,
@@ -422,6 +424,9 @@ class OrganizationsBackend(BaseBackend):
         return root  # type: ignore[return-value]
 
     def create_organization(self, region: str, **kwargs: Any) -> Dict[str, Any]:
+        if self.org or self.account_id in organizations_backends.master_accounts:
+            raise AlreadyInOrganizationException
+
         self.org = FakeOrganization(
             self.account_id,
             region_name=region,
@@ -468,11 +473,12 @@ class OrganizationsBackend(BaseBackend):
         raise AWSOrganizationsNotInUseException
 
     def delete_organization(self) -> None:
+        if self.org is None:
+            raise AWSOrganizationsNotInUseException
+
         if [account for account in self.accounts if account.name != "master"]:
-            raise RESTError(
-                "OrganizationNotEmptyException",
-                "To delete an organization you must first remove all member accounts (except the master).",
-            )
+            raise OrganizationNotEmptyException
+
         self._reset()
 
     def list_roots(self) -> Dict[str, Any]:
@@ -532,6 +538,9 @@ class OrganizationsBackend(BaseBackend):
         ]
 
     def create_account(self, **kwargs: Any) -> Dict[str, Any]:
+        if self.org is None:
+            raise AWSOrganizationsNotInUseException
+
         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)
@@ -542,6 +551,9 @@ class OrganizationsBackend(BaseBackend):
         return new_account.create_account_status
 
     def close_account(self, **kwargs: Any) -> None:
+        if self.org is None:
+            raise AWSOrganizationsNotInUseException
+
         for account in self.accounts:
             if account.id == kwargs["AccountId"]:
                 account.close()
diff --git a/moto/rds/models.py b/moto/rds/models.py
index 8aae7e86d..7ffb42061 100644
--- a/moto/rds/models.py
+++ b/moto/rds/models.py
@@ -2005,7 +2005,7 @@ class RDSBackend(BaseBackend):
                 "You must specify apply immediately when rotating the master user password.",
             )
         database.update(db_kwargs)
-        initial_state = copy.deepcopy(database)
+        initial_state = copy.copy(database)
         database.master_user_secret_status = (
             "active"  # already set the final state in the background
         )
@@ -2065,14 +2065,14 @@ class RDSBackend(BaseBackend):
             db_instance_identifier=source_db_identifier
         )[0]
 
-        # remove the db subnet group as it cannot be copied
-        # and is not used in the restored instance
-        source_dict = db_instance.__dict__
-        if "db_subnet_group" in source_dict:
-            del source_dict["db_subnet_group"]
+        new_instance_props = {}
+        for key, value in db_instance.__dict__.items():
+            # Remove backend / db subnet group as they cannot be copied
+            # and are not used in the restored instance.
+            if key in ("backend", "db_subnet_group"):
+                continue
+            new_instance_props[key] = copy.deepcopy(value)
 
-        new_instance_props = copy.deepcopy(source_dict)
-        new_instance_props.pop("backend")
         if not db_instance.option_group_supplied:
             # If the option group is not supplied originally, the 'option_group_name' will receive a default value
             # Force this reconstruction, and prevent any validation on the default value


Test patch:
diff --git a/tests/test_organizations/test_organizations_boto3.py b/tests/test_organizations/test_organizations_boto3.py
index 81d833297..478d09440 100644
--- a/tests/test_organizations/test_organizations_boto3.py
+++ b/tests/test_organizations/test_organizations_boto3.py
@@ -43,12 +43,25 @@ mockemail = "@".join([mockname, mockdomain])
     [("us-east-1", "aws"), ("cn-north-1", "aws-cn"), ("us-isob-east-1", "aws-iso-b")],
 )
 def test_create_organization(region, partition):
+    if not settings.TEST_DECORATOR_MODE:
+        raise SkipTest("Involves changing account using env variable")
+
     client = boto3.client("organizations", region_name=region)
     response = client.create_organization(FeatureSet="ALL")
     validate_organization(response, partition=partition)
     organization = response["Organization"]
     assert organization["FeatureSet"] == "ALL"
 
+    # Raise if the caller already has an associated org
+    with pytest.raises(ClientError) as exc:
+        client.create_organization(FeatureSet="ALL")
+    assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
+    assert "AlreadyInOrganizationException" in exc.value.response["Error"]["Code"]
+    assert (
+        exc.value.response["Error"]["Message"]
+        == "The provided account is already a member of an organization."
+    )
+
     response = client.list_accounts()
     assert len(response["Accounts"]) == 1
     assert response["Accounts"][0]["Name"] == "master"
@@ -72,6 +85,19 @@ def test_create_organization(region, partition):
     master_account = [t for t in response["Targets"] if t["Type"] == "ACCOUNT"][0]
     assert master_account["Name"] == "master"
 
+    # Raise if a member account attempts to create an org
+    account = client.create_account(AccountName=mockname, Email=mockemail)
+    account_id = account["CreateAccountStatus"]["AccountId"]
+    with mock.patch.dict(os.environ, {"MOTO_ACCOUNT_ID": account_id}):
+        with pytest.raises(ClientError) as exc:
+            boto3.client("organizations", region_name=region).create_organization()
+        assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
+        assert "AlreadyInOrganizationException" in exc.value.response["Error"]["Code"]
+        assert (
+            exc.value.response["Error"]["Message"]
+            == "The provided account is already a member of an organization."
+        )
+
 
 @mock_aws
 def test_create_organization_without_feature_set():
@@ -273,6 +299,17 @@ def test_list_organizational_units_for_parent_exception():
 @mock_aws
 def test_create_account():
     client = boto3.client("organizations", region_name="us-east-1")
+
+    # Raise when creating an account when no org is associated
+    with pytest.raises(ClientError) as exc:
+        client.create_account(AccountName=mockname, Email=mockemail)
+    assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
+    assert "OrganizationsNotInUse" in exc.value.response["Error"]["Code"]
+    assert (
+        exc.value.response["Error"]["Message"]
+        == "Your account is not a member of an organization."
+    )
+
     client.create_organization(FeatureSet="ALL")
     create_status = client.create_account(AccountName=mockname, Email=mockemail)[
         "CreateAccountStatus"
@@ -282,8 +319,19 @@ def test_create_account():
 
 
 @mock_aws
-def test_close_account_returns_nothing():
+def test_close_account():
     client = boto3.client("organizations", region_name="us-east-1")
+
+    # Raise when closing an account when no org is associated
+    with pytest.raises(ClientError) as exc:
+        client.close_account(AccountId="111111111112")
+    assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
+    assert "OrganizationsNotInUse" in exc.value.response["Error"]["Code"]
+    assert (
+        exc.value.response["Error"]["Message"]
+        == "Your account is not a member of an organization."
+    )
+
     client.create_organization(FeatureSet="ALL")
     create_status = client.create_account(AccountName=mockname, Email=mockemail)[
         "CreateAccountStatus"
@@ -292,8 +340,8 @@ def test_close_account_returns_nothing():
 
     resp = client.close_account(AccountId=created_account_id)
 
+    # Ensure CloseAccount returns nothing
     del resp["ResponseMetadata"]
-
     assert resp == {}
 
 
@@ -672,6 +720,17 @@ def test_remove_account_from_organization():
 @mock_aws
 def test_delete_organization_with_existing_account():
     client = boto3.client("organizations", region_name="us-east-1")
+
+    # Raise when attempting to delete an org when none is associated
+    with pytest.raises(ClientError) as exc:
+        client.delete_organization()
+    assert exc.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
+    assert "AWSOrganizationsNotInUseException" in exc.value.response["Error"]["Code"]
+    assert (
+        exc.value.response["Error"]["Message"]
+        == "Your account is not a member of an organization."
+    )
+
     client.create_organization(FeatureSet="ALL")
     create_account_status = client.create_account(
         Email=mockemail, AccountName=mockname
diff --git a/tests/test_rds/test_rds.py b/tests/test_rds/test_rds.py
index 36dddcadf..9003cccc7 100644
--- a/tests/test_rds/test_rds.py
+++ b/tests/test_rds/test_rds.py
@@ -551,9 +551,18 @@ def test_describe_non_existent_database():
         conn.describe_db_instances(DBInstanceIdentifier="not-a-db")
 
 
+@pytest.mark.parametrize(
+    "custom_db_subnet_group", [True, False], ids=("custom_subnet", "default_subnet")
+)
 @mock_aws
-def test_modify_db_instance():
+def test_modify_db_instance(custom_db_subnet_group: bool):
     conn = boto3.client("rds", region_name=DEFAULT_REGION)
+
+    if custom_db_subnet_group:
+        extra_kwargs = {"DBSubnetGroupName": create_db_subnet_group()}
+    else:
+        extra_kwargs = {}
+
     conn.create_db_instance(
         DBInstanceIdentifier="db-id",
         AllocatedStorage=10,
@@ -563,6 +572,7 @@ def test_modify_db_instance():
         MasterUserPassword="hunter2",
         Port=1234,
         DBSecurityGroups=["my_sg"],
+        **extra_kwargs,
     )
     inst = conn.describe_db_instances(DBInstanceIdentifier="db-id")["DBInstances"][0]
     assert inst["AllocatedStorage"] == 10
@@ -1372,13 +1382,7 @@ def test_restore_db_instance_from_db_snapshot(
     )
 
     if custom_db_subnet_group:
-        ec2 = boto3.client("ec2", region_name=DEFAULT_REGION)
-        subnets = ec2.describe_subnets()["Subnets"]
-        conn.create_db_subnet_group(
-            DBSubnetGroupName="custom-subnet-group",
-            DBSubnetGroupDescription="xxx",
-            SubnetIds=[subnets[0]["SubnetId"]],
-        )
+        db_subnet_group_name = create_db_subnet_group()
 
     # restore
     new_instance = conn.restore_db_instance_from_db_snapshot(
@@ -1389,11 +1393,11 @@ def test_restore_db_instance_from_db_snapshot(
         "DBSnapshotIdentifier": db_snapshot_identifier,
     }
     if custom_db_subnet_group:
-        kwargs["DBSubnetGroupName"] = "custom-subnet-group"
+        kwargs["DBSubnetGroupName"] = db_subnet_group_name
     new_instance = conn.restore_db_instance_from_db_snapshot(**kwargs)["DBInstance"]
     if custom_db_subnet_group:
         assert (
-            new_instance["DBSubnetGroup"]["DBSubnetGroupName"] == "custom-subnet-group"
+            new_instance["DBSubnetGroup"]["DBSubnetGroupName"] == db_subnet_group_name
         )
     assert new_instance["DBInstanceIdentifier"] == "db-restore-1"
     assert new_instance["DBInstanceClass"] == "db.m1.small"
@@ -1420,9 +1424,18 @@ def test_restore_db_instance_from_db_snapshot(
     )
 
 
+@pytest.mark.parametrize(
+    "custom_db_subnet_group", [True, False], ids=("custom_subnet", "default_subnet")
+)
 @mock_aws
-def test_restore_db_instance_to_point_in_time():
+def test_restore_db_instance_to_point_in_time(custom_db_subnet_group: bool):
     conn = boto3.client("rds", region_name=DEFAULT_REGION)
+
+    if custom_db_subnet_group:
+        extra_kwargs = {"DBSubnetGroupName": create_db_subnet_group()}
+    else:
+        extra_kwargs = {}
+
     conn.create_db_instance(
         DBInstanceIdentifier="db-primary-1",
         AllocatedStorage=10,
@@ -1432,6 +1445,7 @@ def test_restore_db_instance_to_point_in_time():
         MasterUsername="root",
         MasterUserPassword="hunter2",
         DBSecurityGroups=["my_sg"],
+        **extra_kwargs,
     )
     assert len(conn.describe_db_instances()["DBInstances"]) == 1
 
@@ -2321,6 +2335,19 @@ def test_create_database_in_subnet_group():
     )
 
 
+def create_db_subnet_group(db_subnet_group_name: str = "custom_db_subnet") -> str:
+    ec2 = boto3.client("ec2", region_name=DEFAULT_REGION)
+    first_subnet_id = ec2.describe_subnets()["Subnets"][0]["SubnetId"]
+
+    rds = boto3.client("rds", region_name=DEFAULT_REGION)
+    rds.create_db_subnet_group(
+        DBSubnetGroupName=db_subnet_group_name,
+        DBSubnetGroupDescription="xxx",
+        SubnetIds=[first_subnet_id],
+    )
+    return db_subnet_group_name
+
+
 @mock_aws
 def test_describe_database_subnet_group():
     vpc_conn = boto3.client("ec2", DEFAULT_REGION)