Repository: getmoto/moto
Pull request #8837 (2025-04-21 06:52:56)

feat (organizations): support `TAG_POLICY` type
See https://github.com/localstack/localstack/issues/11530 

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/8837?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 92.82%. Comparing base [(`fcb513c`)](https://app.codecov.io/gh/getmoto/moto/commit/fcb513cee3ca44e676f83f9ce2a13f4e466c2a89?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`a7f6f7b`)](https://app.codecov.io/gh/getmoto/moto/commit/a7f6f7b20c288a41896b0aa9d62077fffea04a8b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).
> Report is 2 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #8837   +/-   ##
=======================================
  Coverage   92.82%   92.82%           
=======================================
  Files        1266     1266           
  Lines      110646   110649    +3     
=======================================
+ Hits       102704   102707    +3     
  Misses       7942     7942           
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/8837/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/8837/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `28.08% <25.00%> (+<0.01%)` | :arrow_up: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/8837/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `92.79% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/8837?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>

Patch:
diff --git a/moto/organizations/models.py b/moto/organizations/models.py
index cb7057efa..a70abe725 100644
--- a/moto/organizations/models.py
+++ b/moto/organizations/models.py
@@ -257,6 +257,8 @@ class FakePolicy(BaseModel):
             self._arn_format = utils.AI_POLICY_ARN_FORMAT
         elif self.type == "SERVICE_CONTROL_POLICY":
             self._arn_format = utils.SCP_ARN_FORMAT
+        elif self.type == "TAG_POLICY":
+            self._arn_format = utils.TAG_POLICY_ARN_FORMAT
         else:
             raise NotImplementedError(
                 f"The {self.type} policy type has not been implemented"
@@ -778,7 +780,11 @@ class OrganizationsBackend(BaseBackend):
         if not FakePolicy.supported_policy_type(_filter):
             raise InvalidInputException("You specified an invalid value.")
 
-        if _filter not in ["AISERVICES_OPT_OUT_POLICY", "SERVICE_CONTROL_POLICY"]:
+        if _filter not in [
+            "AISERVICES_OPT_OUT_POLICY",
+            "SERVICE_CONTROL_POLICY",
+            "TAG_POLICY",
+        ]:
             raise NotImplementedError(
                 f"The {_filter} policy type has not been implemented"
             )
diff --git a/moto/organizations/utils.py b/moto/organizations/utils.py
index 75560c3ec..37368471c 100644
--- a/moto/organizations/utils.py
+++ b/moto/organizations/utils.py
@@ -15,6 +15,7 @@ SCP_ARN_FORMAT = "arn:{0}:organizations::{1}:policy/{2}/service_control_policy/{
 AI_POLICY_ARN_FORMAT = (
     "arn:{0}:organizations::{1}:policy/{2}/aiservices_opt_out_policy/{3}"
 )
+TAG_POLICY_ARN_FORMAT = "arn:{0}:organizations::{1}:policy/{2}/tag_policy/{3}"
 
 CHARSET = string.ascii_lowercase + string.digits
 ORG_ID_SIZE = 10


Test patch:
diff --git a/tests/test_organizations/test_organizations_boto3.py b/tests/test_organizations/test_organizations_boto3.py
index a860fe722..3170e01ea 100644
--- a/tests/test_organizations/test_organizations_boto3.py
+++ b/tests/test_organizations/test_organizations_boto3.py
@@ -2369,3 +2369,52 @@ def test_aiservices_opt_out_policy():
     )
     assert len(response["Policies"]) == 1
     assert response["Policies"][0]["Id"] == policy_id
+
+
+@mock_aws
+def test_tag_policy():
+    # given
+    client = boto3.client("organizations", region_name="us-east-1")
+    org = client.create_organization(FeatureSet="ALL")["Organization"]
+    root_id = client.list_roots()["Roots"][0]["Id"]
+    client.enable_policy_type(RootId=root_id, PolicyType="TAG_POLICY")
+    tag_policy = {
+        "tags": {
+            "CostCenter": {
+                "tag_key": {"@@assign": "CostCenter"},
+                "tag_value": {"@@assign": ["100", "200", "300"]},
+                "enforced_for": {"@@assign": ["ec2:instance", "s3:bucket"]},
+            }
+        }
+    }
+
+    # when
+    response = client.create_policy(
+        Content=json.dumps(tag_policy),
+        Description="Test tag policy",
+        Name="test-tag-policy",
+        Type="TAG_POLICY",
+    )
+
+    # then
+    summary = response["Policy"]["PolicySummary"]
+    policy_id = summary["Id"]
+    assert re.match(utils.POLICY_ID_REGEX, summary["Id"])
+    assert summary["Arn"] == (
+        utils.TAG_POLICY_ARN_FORMAT.format(
+            "aws", org["MasterAccountId"], org["Id"], summary["Id"]
+        )
+    )
+    assert summary["Name"] == "test-tag-policy"
+    assert summary["Description"] == "Test tag policy"
+    assert summary["Type"] == "TAG_POLICY"
+    assert summary["AwsManaged"] is False
+    assert json.loads(response["Policy"]["Content"]) == tag_policy
+
+    # when
+    client.attach_policy(PolicyId=policy_id, TargetId=root_id)
+
+    # then
+    response = client.list_policies_for_target(TargetId=root_id, Filter="TAG_POLICY")
+    assert len(response["Policies"]) == 1
+    assert response["Policies"][0]["Id"] == policy_id