Repository: getmoto/moto
Pull request #8531 (2025-01-28 14:56:17)

Organizations: return organization roots for requests made by other accounts within the organization
Default account is linked to the organization, whereas other accounts within the organization do not have this reference. This PR fixes this issue.

Once an organization is created and a member account is added, the member account can now retrieve the details of the root account by using the `ListRoots` API.

Plus, if a member account is not part of any organization, the `AWSOrganizationsNotInUseException` exception will be raised, as described in the [AWS documentation](https://docs.aws.amazon.com/organizations/latest/APIReference/API_ListRoots.html).


This PR re-uses approach introduced in the #8260


Patch:
diff --git a/moto/dynamodb/responses.py b/moto/dynamodb/responses.py
index 383d77679..3bada2e53 100644
--- a/moto/dynamodb/responses.py
+++ b/moto/dynamodb/responses.py
@@ -879,6 +879,7 @@ class DynamoHandler(BaseResponse):
             result["LastEvaluatedKey"] = last_evaluated_key
         return dynamo_json_dump(result)
 
+    @include_consumed_capacity()
     def delete_item(self) -> str:
         name = self.body["TableName"]
         key = self.body["Key"]
diff --git a/moto/organizations/models.py b/moto/organizations/models.py
index dba3eef7c..3f036076a 100644
--- a/moto/organizations/models.py
+++ b/moto/organizations/models.py
@@ -482,7 +482,18 @@ class OrganizationsBackend(BaseBackend):
         self._reset()
 
     def list_roots(self) -> Dict[str, Any]:
-        return dict(Roots=[ou.describe() for ou in self.ou if isinstance(ou, FakeRoot)])
+        if self.org:
+            return dict(
+                Roots=[ou.describe() for ou in self.ou if isinstance(ou, FakeRoot)]
+            )
+
+        if self.account_id in organizations_backends.master_accounts:
+            master_account_id, partition = organizations_backends.master_accounts[
+                self.account_id
+            ]
+            return organizations_backends[master_account_id][partition].list_roots()
+
+        raise AWSOrganizationsNotInUseException
 
     def create_organizational_unit(self, **kwargs: Any) -> Dict[str, Any]:
         new_ou = FakeOrganizationalUnit(self.org, **kwargs)  # type: ignore
diff --git a/moto/secretsmanager/list_secrets/filters.py b/moto/secretsmanager/list_secrets/filters.py
index 2d0a5b828..57e979014 100644
--- a/moto/secretsmanager/list_secrets/filters.py
+++ b/moto/secretsmanager/list_secrets/filters.py
@@ -1,5 +1,5 @@
 import re
-from typing import TYPE_CHECKING, List
+from typing import TYPE_CHECKING, Iterator, List, Union
 
 if TYPE_CHECKING:
     from ..models import FakeSecret
@@ -76,6 +76,8 @@ def _match_pattern(
             return value.startswith(pattern)
     else:
         pattern_words = split_words(pattern)
+        if not pattern_words:
+            return False
         value_words = split_words(value)
         if not case_sensitive:
             pattern_words = [p.lower() for p in pattern_words]
@@ -90,9 +92,40 @@ def _match_pattern(
 
 
 def split_words(s: str) -> List[str]:
+    """
+    Secrets are split by special characters first (/, +, _, etc)
+    Partial results are then split again by UpperCasing
+    """
+    special_chars = ["/", "-", "_", "+", "=", ".", "@"]
+
+    if s in special_chars:
+        # Special case: this does not return any values
+        return []
+
+    for char in special_chars:
+        if char in s:
+            others = special_chars.copy()
+            others.remove(char)
+            contains_other = any([c in s for c in others])
+            if contains_other:
+                # Secret contains two different characters, i.e. my/secret+value
+                # Values like this will not be split
+                return [s]
+            else:
+                return list(split_by_uppercase(s.split(char)))
+    return list(split_by_uppercase(s))
+
+
+def split_by_uppercase(s: Union[str, List[str]]) -> Iterator[str]:
     """
     Split a string into words. Words are recognized by upper case letters, i.e.:
     test   -> [test]
     MyTest -> [My, Test]
     """
-    return [x.strip() for x in re.split(r"([^a-z][a-z]+)", s) if x]
+    if isinstance(s, str):
+        for x in re.split(r"([^a-z][a-z]+)", s):
+            if x:
+                yield x.strip()
+    else:
+        for word in s:
+            yield from split_by_uppercase(word)


Test patch:
diff --git a/.github/workflows/tests_sdk_ruby.yml b/.github/workflows/tests_sdk_ruby.yml
index 0dfe0966f..c9e3f4289 100644
--- a/.github/workflows/tests_sdk_ruby.yml
+++ b/.github/workflows/tests_sdk_ruby.yml
@@ -13,7 +13,7 @@ jobs:
     steps:
       - uses: actions/checkout@v4
       - name: Set up Ruby ${{ matrix.ruby-version }}
-        uses: ruby/setup-ruby@28c4deda893d5a96a6b2d958c5b47fc18d65c9d3
+        uses: ruby/setup-ruby@1287d2b408066abada82d5ad1c63652e758428d9
         with:
           ruby-version: ${{ matrix.ruby-version }}
       - name: Set up Python 3.8
diff --git a/tests/test_organizations/test_organizations_boto3.py b/tests/test_organizations/test_organizations_boto3.py
index 478d09440..a860fe722 100644
--- a/tests/test_organizations/test_organizations_boto3.py
+++ b/tests/test_organizations/test_organizations_boto3.py
@@ -189,6 +189,42 @@ def test_list_roots():
     validate_roots(org, response)
 
 
+@mock_aws
+def test_list_roots_for_new_account_in_organization():
+    if not settings.TEST_DECORATOR_MODE:
+        raise SkipTest("Involves changing account using env variable")
+    client = boto3.client("organizations", region_name="us-east-1")
+    org = client.create_organization()["Organization"]
+    org_roots = client.list_roots()
+    new_account_id = client.create_account(
+        AccountName="test_account",
+        Email="test@test.xyz",
+        RoleName="CustomOrganizationRole",
+    )["CreateAccountStatus"]["AccountId"]
+
+    with mock.patch.dict(os.environ, {"MOTO_ACCOUNT_ID": new_account_id}):
+        client_for_new_account = boto3.client("organizations", "us-east-1")
+        new_account_roots = client_for_new_account.list_roots()
+        validate_roots(org, new_account_roots)
+        assert len(new_account_roots["Roots"]) == 1
+        assert len(org_roots["Roots"]) == len(new_account_roots["Roots"])
+        assert org_roots["Roots"][0] == new_account_roots["Roots"][0]
+
+
+@mock_aws
+def test_list_roots_for_account_without_organization_exception():
+    client = boto3.client("organizations", region_name="us-east-1")
+    with pytest.raises(ClientError) as e:
+        client.list_roots()
+    ex = e.value
+    assert ex.operation_name == "ListRoots"
+    assert ex.response["ResponseMetadata"]["HTTPStatusCode"] == 400
+    assert "AWSOrganizationsNotInUseException" in ex.response["Error"]["Code"]
+    assert ex.response["Error"]["Message"] == (
+        "Your account is not a member of an organization."
+    )
+
+
 @mock_aws
 def test_create_organizational_unit():
     client = boto3.client("organizations", region_name="us-east-1")
diff --git a/tests/test_secretsmanager/test_list_secrets.py b/tests/test_secretsmanager/test_list_secrets.py
index ba26bba7c..b5c297579 100644
--- a/tests/test_secretsmanager/test_list_secrets.py
+++ b/tests/test_secretsmanager/test_list_secrets.py
@@ -214,6 +214,115 @@ def test_with_all_filter():
         conn.delete_secret(SecretId=no_match, ForceDeleteWithoutRecovery=True)
 
 
+@aws_verified
+# Verified, but not marked because it's flaky - AWS can take up to 5 minutes before secrets are listed
+def test_with_all_filter_special_characters():
+    unique_name = str(uuid4())[0:6]
+    secret_with_slash = f"prod/AppBeta/{unique_name}"
+    secret_with_under = f"prod_AppBeta_{unique_name}"
+    secret_with_plus = f"prod+AppBeta+{unique_name}"
+    secret_with_equal = f"prod=AppBeta={unique_name}"
+    secret_with_dot = f"prod.AppBeta.{unique_name}"
+    secret_with_at = f"prod@AppBeta@{unique_name}"
+    secret_with_dash = f"prod-AppBeta-{unique_name}"
+    # Note that this secret is never found, because the pattern is unknown
+    secret_with_dash_and_slash = f"prod-AppBeta/{unique_name}"
+    full_uppercase = f"uat/COMPANY/{unique_name}"
+    partial_uppercase = f"uat/COMPANYthings/{unique_name}"
+
+    all_special_char_names = [
+        secret_with_slash,
+        secret_with_under,
+        secret_with_plus,
+        secret_with_equal,
+        secret_with_dot,
+        secret_with_at,
+        secret_with_dash,
+    ]
+
+    conn = boto_client()
+
+    conn.create_secret(Name=secret_with_slash, SecretString="s")
+    conn.create_secret(Name=secret_with_under, SecretString="s")
+    conn.create_secret(Name=secret_with_plus, SecretString="s")
+    conn.create_secret(Name=secret_with_equal, SecretString="s")
+    conn.create_secret(Name=secret_with_dot, SecretString="s")
+    conn.create_secret(Name=secret_with_at, SecretString="s")
+    conn.create_secret(Name=secret_with_dash, SecretString="s")
+    conn.create_secret(Name=full_uppercase, SecretString="s")
+    conn.create_secret(Name=partial_uppercase, SecretString="s")
+
+    try:
+        # Partial Match
+        secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["AppBeta"]}])
+        secret_names = [s["Name"] for s in secrets["SecretList"]]
+        assert secret_names == all_special_char_names
+
+        # Partial Match
+        secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["Beta"]}])
+        secret_names = [s["Name"] for s in secrets["SecretList"]]
+        assert secret_names == all_special_char_names
+
+        secrets = conn.list_secrets(
+            Filters=[{"Key": "all", "Values": ["AppBeta", "prod"]}]
+        )["SecretList"]
+        secret_names = [s["Name"] for s in secrets]
+        assert secret_names == all_special_char_names
+
+        # Search for special character itself
+        secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["+"]}])
+        secret_names = [s["Name"] for s in secrets["SecretList"]]
+        assert not secret_names
+
+        # Search for unique postfix
+        secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": [unique_name]}])
+        secret_names = [s["Name"] for s in secrets["SecretList"]]
+        assert secret_names == (
+            all_special_char_names + [full_uppercase, partial_uppercase]
+        )
+
+        # Search for unique postfix
+        secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["company"]}])
+        secret_names = [s["Name"] for s in secrets["SecretList"]]
+        assert secret_names == [full_uppercase]
+
+        # This on it's own is not a word
+        secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["things"]}])
+        secret_names = [s["Name"] for s in secrets["SecretList"]]
+        assert secret_names == []
+
+        # This is valid, because it's split as COMPAN + Ythings
+        secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["Ythings"]}])
+        secret_names = [s["Name"] for s in secrets["SecretList"]]
+        assert secret_names == [partial_uppercase]
+
+        # Note that individual letters from COMPANY are not searchable,
+        # indicating that AWS splits by terms, rather than each individual upper case
+        # COMPANYThings --> COMPAN, YThings
+        secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["N"]}])
+        secret_names = [s["Name"] for s in secrets["SecretList"]]
+        assert secret_names == []
+
+        #
+        secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["pany"]}])
+        secret_names = [s["Name"] for s in secrets["SecretList"]]
+        assert secret_names == []
+
+    finally:
+        conn.delete_secret(SecretId=secret_with_slash, ForceDeleteWithoutRecovery=True)
+        conn.delete_secret(SecretId=secret_with_under, ForceDeleteWithoutRecovery=True)
+        conn.delete_secret(SecretId=secret_with_plus, ForceDeleteWithoutRecovery=True)
+        conn.delete_secret(SecretId=secret_with_equal, ForceDeleteWithoutRecovery=True)
+        conn.delete_secret(SecretId=secret_with_dot, ForceDeleteWithoutRecovery=True)
+        conn.delete_secret(SecretId=secret_with_dash, ForceDeleteWithoutRecovery=True)
+        conn.delete_secret(
+            SecretId=secret_with_dash_and_slash, ForceDeleteWithoutRecovery=True
+        )
+        conn.delete_secret(SecretId=secret_with_at, ForceDeleteWithoutRecovery=True)
+        conn.delete_secret(SecretId=full_uppercase, ForceDeleteWithoutRecovery=True)
+        conn.delete_secret(SecretId=partial_uppercase, ForceDeleteWithoutRecovery=True)
+
+
 @mock_aws
 def test_with_no_filter_key():
     conn = boto_client()
@@ -441,6 +550,7 @@ def test_with_include_planned_deleted_secrets():
         ("MyTestPhrase", ["My", "Test", "Phrase"]),
         ("myTest", ["my", "Test"]),
         ("my test", ["my", "test"]),
+        ("my/test", ["my", "test"]),
     ],
 )
 def test_word_splitter(input, output):