feat(organizations): add OU metadata to outputs (#10283)

Co-authored-by: Raajhesh Kannaa Chidambaram <495042+raajheshkannaa@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
Raajhesh Kannaa Chidambaram
2026-03-11 11:41:44 -04:00
committed by GitHub
parent 125ba830f7
commit 39385567fc
14 changed files with 389 additions and 13 deletions

View File

@@ -11,6 +11,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- Add `trusted_ips` configurable option to `opensearch_service_domains_not_publicly_accessible` check to reduce false positives on IP-restricted policies [(#8631)](https://github.com/prowler-cloud/prowler/pull/8631)
- `guardduty_delegated_admin_enabled_all_regions` check for AWS provider [(#9867)](https://github.com/prowler-cloud/prowler/pull/9867)
- OpenStack object storage service with 7 checks [(#10258)](https://github.com/prowler-cloud/prowler/pull/10258)
- Add AWS Organizations OU metadata (OU ID, OU path) to ASFF, OCSF and CSV outputs [(#10283)](https://github.com/prowler-cloud/prowler/pull/10283)
### 🔄 Changed

View File

@@ -76,6 +76,8 @@ class ASFF(Output):
ProductArn=f"arn:{finding.partition}:securityhub:{finding.region}::product/prowler/prowler",
ProductFields=ProductFields(
ProwlerResourceName=finding.resource_uid,
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
),
GeneratorId="prowler-" + finding.metadata.CheckID,
AwsAccountId=finding.account_uid,
@@ -242,6 +244,8 @@ class ProductFields(BaseModel):
ProviderName: str = "Prowler"
ProviderVersion: str = prowler_version
ProwlerResourceName: str
ProwlerAccountOrganizationalUnitId: Optional[str] = None
ProwlerAccountOrganizationalUnitName: Optional[str] = None
class Severity(BaseModel):

View File

@@ -29,6 +29,8 @@ class CSV(Output):
finding_dict["ACCOUNT_ORGANIZATION_NAME"] = (
finding.account_organization_name
)
finding_dict["ACCOUNT_OU_UID"] = finding.account_ou_uid
finding_dict["ACCOUNT_OU_NAME"] = finding.account_ou_name
finding_dict["ACCOUNT_TAGS"] = unroll_dict(
finding.account_tags, separator=":"
)

View File

@@ -39,6 +39,8 @@ class Finding(BaseModel):
account_email: Optional[str] = None
account_organization_uid: Optional[str] = None
account_organization_name: Optional[str] = None
account_ou_uid: Optional[str] = None
account_ou_name: Optional[str] = None
metadata: CheckMetadata
account_tags: dict = Field(default_factory=dict)
uid: str
@@ -155,6 +157,12 @@ class Finding(BaseModel):
output_data["account_tags"] = get_nested_attribute(
provider, "organizations_metadata.account_tags"
)
output_data["account_ou_uid"] = get_nested_attribute(
provider, "organizations_metadata.account_ou_id"
)
output_data["account_ou_name"] = get_nested_attribute(
provider, "organizations_metadata.account_ou_name"
)
output_data["partition"] = get_nested_attribute(
provider, "identity.partition"
)

View File

@@ -194,7 +194,8 @@ class OCSF(Output):
org=Organization(
uid=finding.account_organization_uid,
name=finding.account_organization_name,
# TODO: add the org unit id and name
ou_uid=finding.account_ou_uid,
ou_name=finding.account_ou_name,
),
provider=finding.provider,
region=finding.region,

View File

@@ -435,14 +435,16 @@ class AwsProvider(Provider):
f"Getting AWS Organizations metadata for account {aws_account_id}"
)
organizations_metadata, list_tags_for_resource = get_organizations_metadata(
organizations_metadata, list_tags_for_resource, ou_metadata = (
get_organizations_metadata(
aws_account_id=aws_account_id,
session=organizations_session,
)
)
if organizations_metadata:
organizations_metadata = parse_organizations_metadata(
organizations_metadata, list_tags_for_resource
organizations_metadata, list_tags_for_resource, ou_metadata
)
logger.info(
f"AWS Organizations metadata retrieved for account {aws_account_id}"

View File

@@ -5,10 +5,44 @@ from prowler.providers.aws.lib.arn.models import ARN
from prowler.providers.aws.models import AWSOrganizationsInfo
def _get_ou_metadata(organizations_client, account_id):
try:
parents = organizations_client.list_parents(ChildId=account_id)["Parents"]
if not parents:
return {"ou_id": "", "ou_path": ""}
parent = parents[0]
if parent["Type"] == "ROOT":
return {"ou_id": "", "ou_path": ""}
direct_ou_id = parent["Id"]
path_parts = []
current_id = direct_ou_id
while True:
ou_info = organizations_client.describe_organizational_unit(
OrganizationalUnitId=current_id
)
path_parts.append(ou_info["OrganizationalUnit"]["Name"])
parents = organizations_client.list_parents(ChildId=current_id)["Parents"]
if not parents or parents[0]["Type"] == "ROOT":
break
current_id = parents[0]["Id"]
path_parts.reverse()
return {"ou_id": direct_ou_id, "ou_path": "/".join(path_parts)}
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return {}
def get_organizations_metadata(
aws_account_id: str,
session: session.Session,
) -> tuple[dict, dict]:
) -> tuple[dict, dict, dict]:
try:
organizations_client = session.client("organizations")
@@ -19,15 +53,19 @@ def get_organizations_metadata(
ResourceId=aws_account_id
)
return organizations_metadata, list_tags_for_resource
ou_metadata = _get_ou_metadata(organizations_client, aws_account_id)
return organizations_metadata, list_tags_for_resource, ou_metadata
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return {}, {}
return {}, {}, {}
def parse_organizations_metadata(metadata: dict, tags: dict) -> AWSOrganizationsInfo:
def parse_organizations_metadata(
metadata: dict, tags: dict, ou_metadata: dict = None
) -> AWSOrganizationsInfo:
try:
# Convert Tags dictionary to String
account_details_tags = {}
@@ -47,6 +85,8 @@ def parse_organizations_metadata(metadata: dict, tags: dict) -> AWSOrganizations
organization_arn=aws_organization_arn,
organization_id=aws_organization_id,
account_tags=account_details_tags,
account_ou_id=ou_metadata.get("ou_id", "") if ou_metadata else "",
account_ou_name=ou_metadata.get("ou_path", "") if ou_metadata else "",
)
except Exception as error:
logger.warning(

View File

@@ -19,6 +19,8 @@ class AWSOrganizationsInfo:
organization_arn: str
organization_id: str
account_tags: list[str]
account_ou_id: str = ""
account_ou_name: str = ""
@dataclass

View File

@@ -55,6 +55,8 @@ class TestASFF:
ProductFields=ProductFields(
ProviderVersion=prowler_version,
ProwlerResourceName=finding.resource_uid,
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
),
GeneratorId="prowler-" + finding.metadata.CheckID,
AwsAccountId=AWS_ACCOUNT_NUMBER,
@@ -123,6 +125,8 @@ class TestASFF:
ProductFields=ProductFields(
ProviderVersion=prowler_version,
ProwlerResourceName=finding.resource_uid,
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
),
GeneratorId="prowler-" + finding.metadata.CheckID,
AwsAccountId=AWS_ACCOUNT_NUMBER,
@@ -190,6 +194,8 @@ class TestASFF:
ProductFields=ProductFields(
ProviderVersion=prowler_version,
ProwlerResourceName=finding.resource_uid,
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
),
GeneratorId="prowler-" + finding.metadata.CheckID,
AwsAccountId=AWS_ACCOUNT_NUMBER,
@@ -261,6 +267,8 @@ class TestASFF:
ProductFields=ProductFields(
ProviderVersion=prowler_version,
ProwlerResourceName=finding.resource_uid,
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
),
GeneratorId="prowler-" + finding.metadata.CheckID,
AwsAccountId=AWS_ACCOUNT_NUMBER,
@@ -470,6 +478,8 @@ class TestASFF:
ProductFields=ProductFields(
ProviderVersion=prowler_version,
ProwlerResourceName=finding.resource_uid,
ProwlerAccountOrganizationalUnitId=finding.account_ou_uid,
ProwlerAccountOrganizationalUnitName=finding.account_ou_name,
),
GeneratorId="prowler-" + finding.metadata.CheckID,
AwsAccountId=AWS_ACCOUNT_NUMBER,
@@ -539,6 +549,8 @@ class TestASFF:
"ProviderName": "Prowler",
"ProviderVersion": prowler_version,
"ProwlerResourceName": "test-arn",
"ProwlerAccountOrganizationalUnitId": "ou-abc1-12345678",
"ProwlerAccountOrganizationalUnitName": "Production/WebServices",
},
"GeneratorId": "prowler-service_test_check_id",
"AwsAccountId": "123456789012",

View File

@@ -59,6 +59,8 @@ class TestCSV:
assert output_data["ACCOUNT_EMAIL"] == ""
assert output_data["ACCOUNT_ORGANIZATION_UID"] == "test-organization-id"
assert output_data["ACCOUNT_ORGANIZATION_NAME"] == "test-organization"
assert output_data["ACCOUNT_OU_UID"] == "ou-abc1-12345678"
assert output_data["ACCOUNT_OU_NAME"] == "Production/WebServices"
assert isinstance(output_data["ACCOUNT_TAGS"], str)
assert output_data["ACCOUNT_TAGS"] == "test-tag:test-value"
assert output_data["FINDING_UID"] == "test-unique-finding"
@@ -121,7 +123,7 @@ class TestCSV:
output.batch_write_data_to_file()
mock_file.seek(0)
expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS\r\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html\r\n"
expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_OU_UID;ACCOUNT_OU_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS\r\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;ou-abc1-12345678;Production/WebServices;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html\r\n"
content = mock_file.read()
assert content == expected_csv
@@ -199,7 +201,7 @@ class TestCSV:
with patch.object(temp_file, "close", return_value=None):
csv.batch_write_data_to_file()
expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html\n"
expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_OU_UID;ACCOUNT_OU_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION;ADDITIONAL_URLS\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;ou-abc1-12345678;Production/WebServices;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version};https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/best-practices.html | https://docs.aws.amazon.com/prescriptive-guidance/latest/migration-operations-integration/introduction.html\n"
temp_file.seek(0)

View File

@@ -151,6 +151,8 @@ class TestFinding:
provider.organizations_metadata.organization_arn = "mock_account_org_uid"
provider.organizations_metadata.organization_id = "mock_account_org_name"
provider.organizations_metadata.account_tags = {"tag1": "value1"}
provider.organizations_metadata.account_ou_id = "ou-test-12345678"
provider.organizations_metadata.account_ou_name = "TestOU/SubOU"
# Mock check result
check_output = MagicMock()
@@ -204,6 +206,8 @@ class TestFinding:
assert finding_output.account_email == "mock_account_email"
assert finding_output.account_organization_uid == "mock_account_org_uid"
assert finding_output.account_organization_name == "mock_account_org_name"
assert finding_output.account_ou_uid == "ou-test-12345678"
assert finding_output.account_ou_name == "TestOU/SubOU"
assert finding_output.account_tags == {"tag1": "value1"}
# Metadata
@@ -241,6 +245,45 @@ class TestFinding:
assert finding_output.service_name == "service"
assert finding_output.raw == {}
def test_generate_output_aws_without_organizations_metadata(self):
# Simulates running without --organizations-role
provider = MagicMock()
provider.type = "aws"
provider.identity.profile = "mock_auth"
provider.identity.account = "mock_account_uid"
provider.identity.partition = "aws"
provider.organizations_metadata = None
check_output = MagicMock()
check_output.resource_id = "test_resource_id"
check_output.resource_arn = "test_resource_arn"
check_output.resource_details = "test_resource_details"
check_output.resource_tags = {}
check_output.region = "us-east-1"
check_output.partition = "aws"
check_output.status = Status.PASS
check_output.status_extended = "mock_status_extended"
check_output.muted = False
check_output.check_metadata = mock_check_metadata(provider="aws")
check_output.resource = {}
check_output.compliance = {}
output_options = MagicMock()
output_options.unix_timestamp = False
finding_output = Finding.generate_output(provider, check_output, output_options)
assert isinstance(finding_output, Finding)
assert finding_output.account_uid == "mock_account_uid"
# get_nested_attribute returns empty string when the attribute chain
# is None, so the Finding fields are "" not None
assert finding_output.account_name == ""
assert finding_output.account_email == ""
assert finding_output.account_organization_uid == ""
assert finding_output.account_organization_name == ""
assert finding_output.account_ou_uid == ""
assert finding_output.account_ou_name == ""
def test_generate_output_azure(self):
# Mock provider
provider = MagicMock()
@@ -799,6 +842,8 @@ class TestFinding:
provider.organizations_metadata.organization_arn = "mock_account_org_uid"
provider.organizations_metadata.organization_id = "mock_account_org_name"
provider.organizations_metadata.account_tags = {"tag1": "value1"}
provider.organizations_metadata.account_ou_id = ""
provider.organizations_metadata.account_ou_name = ""
# Mock check result
check_output = MagicMock()

View File

@@ -45,6 +45,8 @@ def generate_finding_output(
check_title: str = "service_test_check_id",
check_type: list[str] = ["test-type"],
provider_uid: str = None,
account_ou_uid: str = "ou-abc1-12345678",
account_ou_name: str = "Production/WebServices",
) -> Finding:
return Finding(
auth_method="profile: default",
@@ -56,6 +58,8 @@ def generate_finding_output(
account_organization_uid="test-organization-id",
account_organization_name="test-organization",
account_tags={"test-tag": "test-value"},
account_ou_uid=account_ou_uid,
account_ou_name=account_ou_name,
uid="test-unique-finding",
status=status,
status_extended=status_extended,

View File

@@ -264,6 +264,8 @@ class TestOCSF:
"org": {
"name": "test-organization",
"uid": "test-organization-id",
"ou_uid": "ou-abc1-12345678",
"ou_name": "Production/WebServices",
},
"provider": "aws",
"region": "eu-west-1",
@@ -422,6 +424,8 @@ class TestOCSF:
assert isinstance(cloud_organization, Organization)
assert cloud_organization.uid == finding_output.account_organization_uid
assert cloud_organization.name == finding_output.account_organization_name
assert cloud_organization.ou_uid == finding_output.account_ou_uid
assert cloud_organization.ou_name == finding_output.account_ou_name
def test_finding_output_kubernetes(self):
finding_output = generate_finding_output(

View File

@@ -1,7 +1,11 @@
from unittest.mock import MagicMock
import boto3
from botocore.exceptions import ClientError
from moto import mock_aws
from prowler.providers.aws.lib.organizations.organizations import (
_get_ou_metadata,
get_organizations_metadata,
parse_organizations_metadata,
)
@@ -27,8 +31,10 @@ class Test_AWS_Organizations:
ResourceId=account_id, Tags=[{"Key": "key", "Value": "value"}]
)
metadata, tags = get_organizations_metadata(account_id, boto3.Session())
org = parse_organizations_metadata(metadata, tags)
metadata, tags, ou_metadata = get_organizations_metadata(
account_id, boto3.Session()
)
org = parse_organizations_metadata(metadata, tags, ou_metadata)
assert isinstance(org, AWSOrganizationsInfo)
assert org.account_email == mockemail
@@ -43,6 +49,8 @@ class Test_AWS_Organizations:
)
assert org.organization_id == org_id
assert org.account_tags == {"key": "value"}
assert org.account_ou_id == ""
assert org.account_ou_name == ""
def test_parse_organizations_metadata(self):
tags = {"Tags": [{"Key": "test-key", "Value": "test-value"}]}
@@ -71,3 +79,244 @@ class Test_AWS_Organizations:
)
assert org.organization_arn == arn
assert org.account_tags == {"test-key": "test-value"}
assert org.account_ou_id == ""
assert org.account_ou_name == ""
@mock_aws
def test_organizations_with_ou(self):
client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1)
client.create_organization(FeatureSet="ALL")
account_id = client.create_account(
AccountName="ou-account", Email="ou@example.org"
)["CreateAccountStatus"]["AccountId"]
root_id = client.list_roots()["Roots"][0]["Id"]
ou = client.create_organizational_unit(ParentId=root_id, Name="SecurityOU")[
"OrganizationalUnit"
]
client.move_account(
AccountId=account_id,
SourceParentId=root_id,
DestinationParentId=ou["Id"],
)
metadata, tags, ou_metadata = get_organizations_metadata(
account_id, boto3.Session()
)
org = parse_organizations_metadata(metadata, tags, ou_metadata)
assert org.account_ou_id == ou["Id"]
assert org.account_ou_name == "SecurityOU"
@mock_aws
def test_organizations_with_nested_ou(self):
client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1)
client.create_organization(FeatureSet="ALL")
account_id = client.create_account(
AccountName="nested-account", Email="nested@example.org"
)["CreateAccountStatus"]["AccountId"]
root_id = client.list_roots()["Roots"][0]["Id"]
parent_ou = client.create_organizational_unit(
ParentId=root_id, Name="Infrastructure"
)["OrganizationalUnit"]
child_ou = client.create_organizational_unit(
ParentId=parent_ou["Id"], Name="Security"
)["OrganizationalUnit"]
client.move_account(
AccountId=account_id,
SourceParentId=root_id,
DestinationParentId=child_ou["Id"],
)
metadata, tags, ou_metadata = get_organizations_metadata(
account_id, boto3.Session()
)
org = parse_organizations_metadata(metadata, tags, ou_metadata)
assert org.account_ou_id == child_ou["Id"]
assert org.account_ou_name == "Infrastructure/Security"
def test_parse_organizations_metadata_with_ou(self):
tags = {"Tags": []}
metadata = {
"Account": {
"Id": AWS_ACCOUNT_NUMBER,
"Arn": f"arn:aws:organizations::123456789012:account/o-abc123/{AWS_ACCOUNT_NUMBER}",
"Email": "test@example.org",
"Name": "test-account",
"Status": "ACTIVE",
}
}
ou_metadata = {"ou_id": "ou-xxxx-12345678", "ou_path": "Infra/Security"}
org = parse_organizations_metadata(metadata, tags, ou_metadata)
assert org.account_ou_id == "ou-xxxx-12345678"
assert org.account_ou_name == "Infra/Security"
def test_get_ou_metadata_api_error_returns_empty_dict(self):
client = MagicMock()
client.list_parents.side_effect = ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "Access denied"}},
"ListParents",
)
result = _get_ou_metadata(client, "123456789012")
assert result == {}
def test_get_ou_metadata_describe_ou_error_returns_empty_dict(self):
client = MagicMock()
client.list_parents.return_value = {
"Parents": [{"Id": "ou-xxxx-12345678", "Type": "ORGANIZATIONAL_UNIT"}]
}
client.describe_organizational_unit.side_effect = ClientError(
{
"Error": {
"Code": "OrganizationalUnitNotFoundException",
"Message": "OU not found",
}
},
"DescribeOrganizationalUnit",
)
result = _get_ou_metadata(client, "123456789012")
assert result == {}
def test_get_ou_metadata_deeply_nested_three_levels(self):
client = MagicMock()
# First call: account's parent is child OU
# Second call: child OU's parent is mid OU
# Third call: mid OU's parent is top OU
# Fourth call: top OU's parent is ROOT
client.list_parents.side_effect = [
{"Parents": [{"Id": "ou-child", "Type": "ORGANIZATIONAL_UNIT"}]},
{"Parents": [{"Id": "ou-mid", "Type": "ORGANIZATIONAL_UNIT"}]},
{"Parents": [{"Id": "ou-top", "Type": "ORGANIZATIONAL_UNIT"}]},
{"Parents": [{"Id": "r-root", "Type": "ROOT"}]},
]
client.describe_organizational_unit.side_effect = [
{"OrganizationalUnit": {"Id": "ou-child", "Name": "NonProd"}},
{"OrganizationalUnit": {"Id": "ou-mid", "Name": "Workloads"}},
{"OrganizationalUnit": {"Id": "ou-top", "Name": "Root"}},
]
result = _get_ou_metadata(client, "123456789012")
assert result == {"ou_id": "ou-child", "ou_path": "Root/Workloads/NonProd"}
@mock_aws
def test_get_organizations_metadata_api_failure_returns_empty_tuples(self):
# Use a non-existent account ID without creating an organization
metadata, tags, ou_metadata = get_organizations_metadata(
"999999999999", boto3.Session()
)
assert metadata == {}
assert tags == {}
assert ou_metadata == {}
def test_parse_organizations_metadata_with_empty_ou_metadata(self):
tags = {"Tags": []}
metadata = {
"Account": {
"Id": AWS_ACCOUNT_NUMBER,
"Arn": f"arn:aws:organizations::123456789012:account/o-abc123/{AWS_ACCOUNT_NUMBER}",
"Email": "test@example.org",
"Name": "test-account",
"Status": "ACTIVE",
}
}
# Simulates the error path where _get_ou_metadata returns {}
ou_metadata = {}
org = parse_organizations_metadata(metadata, tags, ou_metadata)
assert org.account_ou_id == ""
assert org.account_ou_name == ""
def test_parse_organizations_metadata_with_none_ou_metadata(self):
tags = {"Tags": []}
metadata = {
"Account": {
"Id": AWS_ACCOUNT_NUMBER,
"Arn": f"arn:aws:organizations::123456789012:account/o-abc123/{AWS_ACCOUNT_NUMBER}",
"Email": "test@example.org",
"Name": "test-account",
"Status": "ACTIVE",
}
}
org = parse_organizations_metadata(metadata, tags, None)
assert org.account_ou_id == ""
assert org.account_ou_name == ""
@mock_aws
def test_end_to_end_ou_metadata_flows_to_organizations_info(self):
"""Integration test: exercises get_organizations_metadata →
parse_organizations_metadata with a nested OU, verifying the full
data flow that AwsProvider.get_organizations_info relies on."""
client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1)
client.create_organization(FeatureSet="ALL")
account_id = client.create_account(
AccountName="e2e-account", Email="e2e@example.org"
)["CreateAccountStatus"]["AccountId"]
root_id = client.list_roots()["Roots"][0]["Id"]
top_ou = client.create_organizational_unit(ParentId=root_id, Name="Workloads")[
"OrganizationalUnit"
]
child_ou = client.create_organizational_unit(
ParentId=top_ou["Id"], Name="NonProd"
)["OrganizationalUnit"]
client.move_account(
AccountId=account_id,
SourceParentId=root_id,
DestinationParentId=child_ou["Id"],
)
client.tag_resource(
ResourceId=account_id,
Tags=[{"Key": "Environment", "Value": "dev"}],
)
# Full flow: get → parse → AWSOrganizationsInfo
metadata, tags, ou_metadata = get_organizations_metadata(
account_id, boto3.Session()
)
org = parse_organizations_metadata(metadata, tags, ou_metadata)
assert isinstance(org, AWSOrganizationsInfo)
assert org.account_name == "e2e-account"
assert org.account_email == "e2e@example.org"
assert org.account_tags == {"Environment": "dev"}
assert org.account_ou_id == child_ou["Id"]
assert org.account_ou_name == "Workloads/NonProd"
@mock_aws
def test_end_to_end_account_under_root_has_empty_ou(self):
"""Integration test: account directly under Root should produce
empty OU fields, not errors."""
client = boto3.client("organizations", region_name=AWS_REGION_US_EAST_1)
client.create_organization(FeatureSet="ALL")
account_id = client.create_account(
AccountName="root-account", Email="root@example.org"
)["CreateAccountStatus"]["AccountId"]
metadata, tags, ou_metadata = get_organizations_metadata(
account_id, boto3.Session()
)
org = parse_organizations_metadata(metadata, tags, ou_metadata)
assert isinstance(org, AWSOrganizationsInfo)
assert org.account_ou_id == ""
assert org.account_ou_name == ""