Repository: lyft/cartography

Issue:
cartography treats managed IAM policies as if they were inline policies
*Description*:
> What issue is being seen?  Describe what should be happening instead of the bug, for example: Cartography should not crash, the expected value isn't returned, the data schema is wrong, etc.

cartography treats IAM managed policies as if they were inline policies.


*To Reproduce*:
> Steps to reproduce the behavior.  Provide all data and inputs required to reproduce the issue.

Run the cartography IAM sync.

*Screenshots*:
> If applicable, add screenshots to help explain your problem.

**Expected behavior**:

If I run `match(s:AWSPolicy{name:"policy-name"})--(p:AWSPrincipal) return *  limit 30;`, I should get a graph with a one-to-many relationship from policy to principals (assuming that 'policy-name' is of type 'managed' and not 'inline'). It should look like this:

```
  (     policy   )
  /        |     \
(prin1) (prin2) (prin3)
```

In total I should see 1 policy and N principals attached to it.

**Actual behavior**:

I run `match(s:AWSPolicy{name:"policy-name"})--(p:AWSPrincipal) return *  limit 30;` and see N policies and N principals:
![image](https://user-images.githubusercontent.com/46503781/209777819-027fb03c-51ee-44c4-96ac-c488c175a212.png)

*Please complete the following information:*:
>  - Cartography release version or commit hash [e.g. 0.12.0 or 95e8e11913e2a44a4d4682506d8364a638ceac69]
0.69.0

*Additional context*:
> Add any other context about the problem here.

This is because all calls to load managed policies all get [funneled](https://github.com/lyft/cartography/blob/585fc0667feda4cf0d42ebe428cc3ab36fe6432e/cartography/intel/aws/iam.py#L661) to [load_policy_data()](https://github.com/lyft/cartography/blob/585fc0667feda4cf0d42ebe428cc3ab36fe6432e/cartography/intel/aws/iam.py#L594-L600), which handles the data like an inline policy: it generates the policy ID based on the principal that it is attached to.

I'm pretty sure this is a bug since managed policies should not have unique IDs based on the principals that they are attached to.

Discussion:

Patch:
diff --git a/cartography/intel/aws/iam.py b/cartography/intel/aws/iam.py
index 90662b94..c652a3f2 100644
--- a/cartography/intel/aws/iam.py
+++ b/cartography/intel/aws/iam.py
@@ -28,6 +28,10 @@ class PolicyType(enum.Enum):
     inline = 'inline'
 
 
+def get_policy_name_from_arn(arn: str) -> str:
+    return arn.split("/")[-1]
+
+
 @timeit
 def get_group_policies(boto3_session: boto3.session.Session, group_name: str) -> Dict:
     client = boto3_session.client('iam')
@@ -76,10 +80,10 @@ def get_group_managed_policy_data(boto3_session: boto3.session.Session, group_li
     policies = {}
     for group in group_list:
         name = group["GroupName"]
-        arn = group["Arn"]
+        group_arn = group["Arn"]
         resource_group = resource_client.Group(name)
-        policies[arn] = {
-            p.policy_name: p.default_version.document["Statement"]
+        policies[group_arn] = {
+            p.arn: p.default_version.document["Statement"]
             for p in resource_group.attached_policies.all()
         }
     return policies
@@ -108,11 +112,11 @@ def get_user_managed_policy_data(boto3_session: boto3.session.Session, user_list
     policies = {}
     for user in user_list:
         name = user["UserName"]
-        arn = user["Arn"]
+        user_arn = user["Arn"]
         resource_user = resource_client.User(name)
         try:
-            policies[arn] = {
-                p.policy_name: p.default_version.document["Statement"]
+            policies[user_arn] = {
+                p.arn: p.default_version.document["Statement"]
                 for p in resource_user.attached_policies.all()
             }
         except resource_client.meta.client.exceptions.NoSuchEntityException:
@@ -145,11 +149,11 @@ def get_role_managed_policy_data(boto3_session: boto3.session.Session, role_list
     policies = {}
     for role in role_list:
         name = role["RoleName"]
-        arn = role["Arn"]
+        role_arn = role["Arn"]
         resource_role = resource_client.Role(name)
         try:
-            policies[arn] = {
-                p.policy_name: p.default_version.document["Statement"]
+            policies[role_arn] = {
+                p.arn: p.default_version.document["Statement"]
                 for p in resource_role.attached_policies.all()
             }
         except resource_client.meta.client.exceptions.NoSuchEntityException:
@@ -511,10 +515,11 @@ def _transform_policy_statements(statements: Any, policy_id: str) -> List[Dict]:
 
 
 def transform_policy_data(policy_map: Dict, policy_type: str) -> None:
-    for principal_arn, policy_list in policy_map.items():
-        logger.debug(f"Syncing IAM {policy_type} policies for principal {principal_arn}")
-        for policy_name, statements in policy_list.items():
-            policy_id = transform_policy_id(principal_arn, policy_type, policy_name)
+    for principal_arn, policy_statement_map in policy_map.items():
+        logger.debug(f"Transforming IAM {policy_type} policies for principal {principal_arn}")
+        for policy_key, statements in policy_statement_map.items():
+            policy_id = transform_policy_id(principal_arn, policy_type, policy_key) \
+                if policy_type == PolicyType.inline.value else policy_key
             statements = _transform_policy_statements(
                 statements, policy_id,
             )
@@ -591,11 +596,21 @@ def load_policy_statements(
 
 
 @timeit
-def load_policy_data(neo4j_session: neo4j.Session, policy_map: Dict, policy_type: str, aws_update_tag: int) -> None:
-    for principal_arn, policy_list in policy_map.items():
-        logger.debug(f"Syncing IAM inline policies for principal {principal_arn}")
-        for policy_name, statements in policy_list.items():
-            policy_id = transform_policy_id(principal_arn, policy_type, policy_name)
+def load_policy_data(
+        neo4j_session: neo4j.Session,
+        principal_policy_map: Dict[str, Dict[str, Any]],
+        policy_type: str,
+        aws_update_tag: int,
+) -> None:
+    for principal_arn, policy_statement_map in principal_policy_map.items():
+        logger.debug(f"Loading policies for principal {principal_arn}")
+        for policy_key, statements in policy_statement_map.items():
+            policy_name = policy_key if policy_type == PolicyType.inline.value else get_policy_name_from_arn(policy_key)
+            policy_id = transform_policy_id(
+                principal_arn,
+                policy_type,
+                policy_key,
+            ) if policy_type == PolicyType.inline.value else policy_key
             load_policy(neo4j_session, policy_id, policy_name, policy_type, principal_arn, aws_update_tag)
             load_policy_statements(neo4j_session, policy_id, policy_name, statements, aws_update_tag)
 


Test patch:
diff --git a/tests/data/aws/iam.py b/tests/data/aws/iam/__init__.py
similarity index 100%
rename from tests/data/aws/iam.py
rename to tests/data/aws/iam/__init__.py
diff --git a/tests/data/aws/iam/role_policies.py b/tests/data/aws/iam/role_policies.py
new file mode 100644
index 00000000..12fb7e80
--- /dev/null
+++ b/tests/data/aws/iam/role_policies.py
@@ -0,0 +1,173 @@
+from datetime import datetime
+
+
+ANOTHER_GET_ROLE_LIST_DATASET = {
+    'Roles': [
+        {
+            'Path': '/',
+            'RoleName': 'ServiceRole',
+            'RoleId': 'AROAJJUSH7VXXX',
+            'Arn': 'arn:aws:iam::1234:role/ServiceRole',
+            'CreateDate': datetime(2018, 8, 2, 23, 4, 13),
+            'AssumeRolePolicyDocument': {
+                'Version': '2012-10-17',
+                'Statement': [{
+                    'Effect': 'Allow',
+                    'Principal': {'AWS': 'arn:aws:iam::54321:root'},
+                    'Action': 'sts:AssumeRole',
+                    'Condition': {},
+                }],
+            },
+            'MaxSessionDuration': 3600,
+        },
+        {
+            'Path': '/',
+            'RoleName': 'ElasticacheAutoscale',
+            'RoleId': 'AROAXJNIGTSXXY',
+            'Arn': 'arn:aws:iam::1234:role/ElasticacheAutoscale',
+            'CreateDate': datetime(2019, 8, 13, 20, 10, 58),
+            'AssumeRolePolicyDocument': {
+                'Version': '2012-10-17',
+                'Statement': [{
+                    'Effect': 'Allow',
+                    'Principal': {'Service': 'lambda.amazonaws.com'},
+                    'Action': 'sts:AssumeRole',
+                }],
+            },
+            'Description': 'Allows Lambda functions to call AWS services on your behalf.',
+            'MaxSessionDuration': 3600,
+        },
+        {
+            'Path': '/',
+            'RoleName': 'sftp-LambdaExecutionRole-1234',
+            'RoleId': 'AROAXJNIGTSXYY',
+            'Arn': 'arn:aws:iam::1234:role/sftp-LambdaExecutionRole-1234',
+            'CreateDate': datetime(2019, 9, 18, 5, 1, 21),
+            'AssumeRolePolicyDocument': {
+                'Version': '2012-10-17',
+                'Statement': [{
+                    'Effect': 'Allow',
+                    'Principal': {'Service': 'lambda.amazonaws.com'},
+                    'Action': 'sts:AssumeRole',
+                }],
+            },
+            'Description': '',
+            'MaxSessionDuration': 3600,
+        },
+    ],
+}
+
+GET_ROLE_MANAGED_POLICY_DATA = {
+    'arn:aws:iam::1234:role/ServiceRole': {},
+    'arn:aws:iam::1234:role/ElasticacheAutoscale': {
+        'arn:aws:iam::1234:policy/AWSLambdaBasicExecutionRole-autoscaleElasticache': [
+            {
+                'Effect': 'Allow',
+                'Action': 'logs:CreateLogGroup',
+                'Resource': 'arn:aws:logs:us-east-1:1234:*',
+            },
+            {
+                'Effect': 'Allow',
+                'Action': ['logs:CreateLogStream', 'logs:PutLogEvents'],
+                'Resource': ['arn:aws:logs:us-east-1:1234:log-group:/aws/lambda/*:*'],
+            },
+        ],
+        'arn:aws:iam::aws:policy/AWSLambdaFullAccess': [{
+            'Effect': 'Allow',
+            'Action': [
+                'cloudformation:DescribeChangeSet',
+                'cloudformation:DescribeStackResources',
+                'cloudformation:DescribeStacks',
+                'cloudformation:GetTemplate',
+                'cloudformation:ListStackResources',
+                'cloudwatch:*',
+                'cognito-identity:ListIdentityPools',
+                'cognito-sync:GetCognitoEvents',
+                'cognito-sync:SetCognitoEvents',
+                'dynamodb:*',
+                'ec2:DescribeSecurityGroups',
+                'ec2:DescribeSubnets',
+                'ec2:DescribeVpcs',
+                'events:*',
+                'iam:GetPolicy',
+                'iam:GetPolicyVersion',
+                'iam:GetRole',
+                'iam:GetRolePolicy',
+                'iam:ListAttachedRolePolicies',
+                'iam:ListRolePolicies',
+                'iam:ListRoles',
+                'iam:PassRole',
+                'iot:AttachPrincipalPolicy',
+                'iot:AttachThingPrincipal',
+                'iot:CreateKeysAndCertificate',
+                'iot:CreatePolicy',
+                'iot:CreateThing',
+                'iot:CreateTopicRule',
+                'iot:DescribeEndpoint',
+                'iot:GetTopicRule',
+                'iot:ListPolicies',
+                'iot:ListThings',
+                'iot:ListTopicRules',
+                'iot:ReplaceTopicRule',
+                'kinesis:DescribeStream',
+                'kinesis:ListStreams',
+                'kinesis:PutRecord',
+                'kms:ListAliases',
+                'lambda:*',
+                'logs:*',
+                's3:*',
+                'sns:ListSubscriptions',
+                'sns:ListSubscriptionsByTopic',
+                'sns:ListTopics',
+                'sns:Publish',
+                'sns:Subscribe',
+                'sns:Unsubscribe',
+                'sqs:ListQueues',
+                'sqs:SendMessage',
+                'tag:GetResources',
+                'xray:PutTelemetryRecords',
+                'xray:PutTraceSegments',
+            ],
+            'Resource': '*',
+        }],
+        'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole': [{
+            'Effect': 'Allow',
+            'Action': [
+                'logs:CreateLogGroup',
+                'logs:CreateLogStream',
+                'logs:PutLogEvents',
+            ],
+            'Resource': '*',
+        }],
+        'arn:aws:iam::aws:policy/service-role/AWSLambdaRole': [{
+            'Effect': 'Allow',
+            'Action': ['lambda:InvokeFunction'],
+            'Resource': ['*'],
+        }],
+        'arn:aws:iam::aws:policy/AmazonElastiCacheFullAccess': [
+            {
+                'Action': 'elasticache:*',
+                'Effect': 'Allow',
+                'Resource': '*',
+            },
+            {
+                'Action': 'iam:CreateServiceLinkedRole',
+                'Effect': 'Allow',
+                'Resource': 'arn:aws:iam::*:role/aws-service-role/elasticache.amazonaws.com'
+                            '/AWSServiceRoleForElastiCache',
+                'Condition': {'StringLike': {'iam:AWSServiceName': 'elasticache.amazonaws.com'}},
+            },
+        ],
+    },
+    'arn:aws:iam::1234:role/sftp-LambdaExecutionRole-1234': {
+        'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole': [{
+            'Effect': 'Allow',
+            'Action': [
+                'logs:CreateLogGroup',
+                'logs:CreateLogStream',
+                'logs:PutLogEvents',
+            ],
+            'Resource': '*',
+        }],
+    },
+}
diff --git a/tests/data/aws/iam/user_policies.py b/tests/data/aws/iam/user_policies.py
new file mode 100644
index 00000000..4f00b6de
--- /dev/null
+++ b/tests/data/aws/iam/user_policies.py
@@ -0,0 +1,206 @@
+from datetime import datetime
+
+
+GET_USER_LIST_DATA = {
+    'Users': [
+        {
+            'Path': '/',
+            'UserName': 'user1',
+            'UserId': 'AIDAXJNIGTSXXX',
+            'Arn': 'arn:aws:iam::1234:user/user1',
+            'CreateDate': datetime(2022, 7, 27, 20, 24, 23),
+        },
+        {
+            'Path': '/',
+            'UserName': 'user2',
+            'UserId': 'AIDAXJNIGTSXXY',
+            'Arn': 'arn:aws:iam::1234:user/user2',
+            'CreateDate': datetime(2021, 1, 25, 18, 8, 53),
+        },
+        {
+            'Path': '/',
+            'UserName': 'user3',
+            'UserId': 'AIDAXJNIGTSQXYY',
+            'Arn': 'arn:aws:iam::1234:user/user3',
+            'CreateDate': datetime(2020, 3, 23, 20, 26, 23),
+        },
+    ],
+}
+
+
+GET_USER_MANAGED_POLS_SAMPLE = {
+    'arn:aws:iam::1234:user/user1': {
+        'arn:aws:iam::1234:policy/user1-user-policy': [
+            {
+                'Sid': 'VisualEditor0',
+                'Effect': 'Allow',
+                'Action': ['iam:DeleteRolePolicy', 'iam:CreateRole', 'iam:PutRolePolicy'],
+                'Resource': '*',
+            },
+            {
+                'Sid': 'VisualEditor1',
+                'Effect': 'Allow',
+                'Action': 'iam:PutRolePolicy',
+                'Resource': 'arn:aws:iam::1234:role/lambda-user1-exec',
+            },
+        ],
+        'arn:aws:iam::aws:policy/AmazonS3FullAccess': [{
+            'Effect': 'Allow',
+            'Action': ['s3:*', 's3-object-lambda:*'],
+            'Resource': '*',
+        }],
+        'arn:aws:iam::aws:policy/AWSLambda_FullAccess': [
+            {
+                'Effect': 'Allow',
+                'Action': [
+                    'cloudformation:DescribeStacks',
+                    'cloudformation:ListStackResources',
+                    'cloudwatch:ListMetrics',
+                    'cloudwatch:GetMetricData',
+                    'ec2:DescribeSecurityGroups',
+                    'ec2:DescribeSubnets',
+                    'ec2:DescribeVpcs',
+                    'kms:ListAliases',
+                    'iam:GetPolicy',
+                    'iam:GetPolicyVersion',
+                    'iam:GetRole',
+                    'iam:GetRolePolicy',
+                    'iam:ListAttachedRolePolicies',
+                    'iam:ListRolePolicies',
+                    'iam:ListRoles',
+                    'lambda:*',
+                    'logs:DescribeLogGroups',
+                    'states:DescribeStateMachine',
+                    'states:ListStateMachines',
+                    'tag:GetResources',
+                    'xray:GetTraceSummaries',
+                    'xray:BatchGetTraces',
+                ],
+                'Resource': '*',
+            },
+            {
+                'Effect': 'Allow',
+                'Action': 'iam:PassRole',
+                'Resource': '*',
+                'Condition': {'StringEquals': {'iam:PassedToService': 'lambda.amazonaws.com'}},
+            },
+            {
+                'Effect': 'Allow',
+                'Action': [
+                    'logs:DescribeLogStreams',
+                    'logs:GetLogEvents',
+                    'logs:FilterLogEvents',
+                ],
+                'Resource': 'arn:aws:logs:*:*:log-group:/aws/lambda/*',
+            },
+        ],
+    },
+    'arn:aws:iam::1234:user/user2': {},
+    'arn:aws:iam::1234:user/user3': {
+        'arn:aws:iam::aws:policy/AdministratorAccess': [{
+            'Effect': 'Allow',
+            'Action': '*',
+            'Resource': '*',
+        }],
+    },
+}
+
+
+GET_ROLE_LIST_DATA = {
+    'Roles': [
+        {
+            'Path': '/',
+            'RoleName': 'ServiceRole',
+            'RoleId': 'AROAJJUSH7VXXX',
+            'Arn': 'arn:aws:iam::1234:role/ServiceRole',
+            'CreateDate': datetime(2018, 8, 2, 23, 4, 13),
+            'AssumeRolePolicyDocument': {
+                'Version': '2012-10-17',
+                'Statement': [{
+                    'Effect': 'Allow',
+                    'Principal': {'AWS': 'arn:aws:iam::54321:root'},
+                    'Action': 'sts:AssumeRole',
+                    'Condition': {},
+                }],
+            },
+            'MaxSessionDuration': 360,
+        },
+        {
+            'Path': '/',
+            'RoleName': 'cartography-read-only',
+            'RoleId': 'AROAJGFIQ7EXXY',
+            'Arn': 'arn:aws:iam::1234:role/cartography-read-only',
+            'CreateDate': datetime(2018, 6, 19, 0, 42, 12),
+            'AssumeRolePolicyDocument': {
+                'Version': '2012-10-17',
+                'Statement': [{
+                    'Effect': 'Allow',
+                    'Principal': {'AWS': 'arn:aws:iam::54321:root'},
+                    'Action': 'sts:AssumeRole',
+                }],
+            },
+            'MaxSessionDuration': 360,
+        },
+        {
+            'Path': '/',
+            'RoleName': 'admin',
+            'RoleId': 'AROAJPGNHE3XYY',
+            'Arn': 'arn:aws:iam::1234:role/admin',
+            'CreateDate': datetime(2016, 4, 25, 22, 5, 43),
+            'AssumeRolePolicyDocument': {
+                'Version': '2012-10-17',
+                'Statement': [
+                    {
+                        'Effect': 'Allow',
+                        'Principal': {'AWS': 'arn:aws:iam::54321:root'},
+                        'Action': 'sts:AssumeRole',
+                        'Condition': {'Bool': {'aws:MultiFactorAuthPresent': 'true'}},
+                    },
+                    {
+                        'Effect': 'Allow',
+                        'Principal': {'AWS': 'arn:aws:iam::98765:root'},
+                        'Action': 'sts:AssumeRole',
+                        'Condition': {'Bool': {'aws:MultiFactorAuthPresent': 'true'}},
+                    },
+                    {
+                        'Effect': 'Allow',
+                        'Principal': {'Federated': 'arn:aws:iam::1234:saml-provider/Okta'},
+                        'Action': 'sts:AssumeRoleWithSAML',
+                        'Condition': {'StringEquals': {'SAML:aud': 'https://signin.aws.amazon.com/saml'}},
+                    },
+                ],
+            },
+            'MaxSessionDuration': 144,
+        },
+    ],
+}
+
+
+GET_ROLE_INLINE_POLS_SAMPLE = {
+    'arn:aws:iam::1234:role/ServiceRole': {
+        'ServiceRole': [{
+            'Sid': 'VisualEditor0',
+            'Effect': 'Allow',
+            'Action': [
+                'iam:ListPolicies',
+                'iam:GetAccountSummary',
+                'iam:ListAccountAliases',
+                'iam:GenerateServiceLastAccessedDetails',
+                'iam:ListRoles',
+                'iam:ListUsers',
+                'iam:ListGroups',
+                'iam:GetServiceLastAccessedDetails',
+                'iam:ListRolePolicies',
+            ],
+            'Resource': '*',
+        }],
+    },
+    'arn:aws:iam::1234:role/cartography-read-only': {},
+    'arn:aws:iam::1234:role/admin': {
+        'AdministratorAccess': [{
+            'Effect': 'Allow',
+            'Action': '*',
+            'Resource': '*',
+        }],
+    },
+}
diff --git a/tests/integration/cartography/intel/aws/iam/__init__.py b/tests/integration/cartography/intel/aws/iam/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/integration/cartography/intel/aws/test_iam.py b/tests/integration/cartography/intel/aws/iam/test_iam.py
similarity index 100%
rename from tests/integration/cartography/intel/aws/test_iam.py
rename to tests/integration/cartography/intel/aws/iam/test_iam.py
diff --git a/tests/integration/cartography/intel/aws/iam/test_role_policies.py b/tests/integration/cartography/intel/aws/iam/test_role_policies.py
new file mode 100644
index 00000000..e15a4b48
--- /dev/null
+++ b/tests/integration/cartography/intel/aws/iam/test_role_policies.py
@@ -0,0 +1,113 @@
+from unittest import mock
+from unittest.mock import MagicMock
+
+import cartography.intel.aws.iam
+from cartography.intel.aws.iam import sync_roles
+from tests.data.aws.iam.role_policies import ANOTHER_GET_ROLE_LIST_DATASET
+from tests.data.aws.iam.role_policies import GET_ROLE_MANAGED_POLICY_DATA
+from tests.data.aws.iam.user_policies import GET_ROLE_INLINE_POLS_SAMPLE
+from tests.data.aws.iam.user_policies import GET_ROLE_LIST_DATA
+
+AWS_UPDATE_TAG = 111111
+AWS_ACCOUNT_ID = '1234'
+PARAMS = {'UPDATE_TAG': AWS_UPDATE_TAG, 'AWS_ID': AWS_ACCOUNT_ID}
+
+
+@mock.patch.object(cartography.intel.aws.iam, 'sync_role_managed_policies')  # because this test is for inline pols only
+@mock.patch.object(cartography.intel.aws.iam, 'get_role_list_data', return_value=GET_ROLE_LIST_DATA)
+@mock.patch.object(cartography.intel.aws.iam, 'get_role_policy_data', return_value=GET_ROLE_INLINE_POLS_SAMPLE)
+def test_sync_role_inline_policies(
+        mock_get_role_inline_pols: MagicMock,
+        mock_get_role_list: MagicMock,
+        mock_sync_role_managed_pols: MagicMock,
+        neo4j_session,
+) -> None:
+    # Arrange
+    boto3_session = mock.MagicMock()
+    neo4j_session.run("MERGE (a:AWSAccount{id:$AccountId})", AccountId=AWS_ACCOUNT_ID)
+
+    # Act
+    sync_roles(neo4j_session, boto3_session, AWS_ACCOUNT_ID, AWS_UPDATE_TAG, PARAMS)
+
+    # Assert that we create policies with expected values for ids.
+    result = neo4j_session.run(
+        """
+        MATCH (role:AWSRole)-[:POLICY]->(pol:AWSPolicy) RETURN role.arn, pol.id;
+        """,
+    )
+
+    # Define the relationships we expect in terms of role ARN and principal ARN.
+    expected = {
+        (
+            'arn:aws:iam::1234:role/ServiceRole',
+            'arn:aws:iam::1234:role/ServiceRole/inline_policy/ServiceRole',
+        ),
+        (
+            'arn:aws:iam::1234:role/admin',
+            'arn:aws:iam::1234:role/admin/inline_policy/AdministratorAccess',
+        ),
+    }
+    actual = {
+        (r['role.arn'], r['pol.id']) for r in result
+    }
+    # Compare our actual results to our expected results.
+    assert actual == expected
+
+
+@mock.patch.object(cartography.intel.aws.iam, 'sync_role_inline_policies')  # because this is for managed pols only
+@mock.patch.object(cartography.intel.aws.iam, 'get_role_list_data', return_value=ANOTHER_GET_ROLE_LIST_DATASET)
+@mock.patch.object(cartography.intel.aws.iam, 'get_role_managed_policy_data', return_value=GET_ROLE_MANAGED_POLICY_DATA)
+def test_sync_role_managed_policies(
+        mock_get_role_managed_pols: MagicMock,
+        mock_get_role_list: MagicMock,
+        mock_sync_role_inline_pols: MagicMock,
+        neo4j_session,
+) -> None:
+    # Arrange
+    boto3_session = mock.MagicMock()
+    # Start with a clean graph
+    neo4j_session.run("MATCH (n) DETACH DELETE n")
+    neo4j_session.run("MERGE (a:AWSAccount{id:$AccountId})", AccountId=AWS_ACCOUNT_ID)
+
+    # Act
+    sync_roles(neo4j_session, boto3_session, AWS_ACCOUNT_ID, AWS_UPDATE_TAG, PARAMS)
+
+    # Assert that we create policies with expected values for ids.
+    result = neo4j_session.run(
+        """
+        MATCH (role:AWSRole)-[:POLICY]->(pol:AWSPolicy) RETURN role.arn, pol.id;
+        """,
+    )
+
+    # Expect that 2 roles are attached to the same policy called "AWSLambdaBasicExecutionRole"
+    expected = {
+        (
+            'arn:aws:iam::1234:role/ElasticacheAutoscale',
+            'arn:aws:iam::1234:policy/AWSLambdaBasicExecutionRole-autoscaleElasticache',
+        ),
+        (
+            'arn:aws:iam::1234:role/ElasticacheAutoscale',
+            'arn:aws:iam::aws:policy/AWSLambdaFullAccess',
+        ),
+        (
+            'arn:aws:iam::1234:role/ElasticacheAutoscale',
+            'arn:aws:iam::aws:policy/AmazonElastiCacheFullAccess',
+        ),
+        (
+            'arn:aws:iam::1234:role/ElasticacheAutoscale',
+            'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
+        ),
+        (
+            'arn:aws:iam::1234:role/ElasticacheAutoscale',
+            'arn:aws:iam::aws:policy/service-role/AWSLambdaRole',
+        ),
+        (
+            'arn:aws:iam::1234:role/sftp-LambdaExecutionRole-1234',
+            'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
+        ),
+    }
+    actual = {
+        (r['role.arn'], r['pol.id']) for r in result
+    }
+    # Compare our actual results to our expected results.
+    assert actual == expected
diff --git a/tests/integration/cartography/intel/aws/iam/test_user_policies.py b/tests/integration/cartography/intel/aws/iam/test_user_policies.py
new file mode 100644
index 00000000..38735f06
--- /dev/null
+++ b/tests/integration/cartography/intel/aws/iam/test_user_policies.py
@@ -0,0 +1,48 @@
+from unittest import mock
+from unittest.mock import MagicMock
+
+import cartography.intel.aws.iam
+from cartography.intel.aws.iam import sync_users
+from tests.data.aws.iam.user_policies import GET_USER_LIST_DATA
+from tests.data.aws.iam.user_policies import GET_USER_MANAGED_POLS_SAMPLE
+
+AWS_UPDATE_TAG = 111111
+AWS_ACCOUNT_ID = '000000'
+PARAMS = {'UPDATE_TAG': AWS_UPDATE_TAG, 'AWS_ID': AWS_ACCOUNT_ID}
+
+
+@mock.patch.object(cartography.intel.aws.iam, 'sync_user_inline_policies')  # because this test is for managed pols only
+@mock.patch.object(cartography.intel.aws.iam, 'get_user_list_data', return_value=GET_USER_LIST_DATA)
+@mock.patch.object(cartography.intel.aws.iam, 'get_user_managed_policy_data', return_value=GET_USER_MANAGED_POLS_SAMPLE)
+def test_sync_user_managed_policies(
+        mock_get_user_pols: MagicMock,
+        mock_get_user_list: MagicMock,
+        mock_sync_user_inline_pols: MagicMock,
+        neo4j_session,
+) -> None:
+    # Arrange
+    boto3_session = mock.MagicMock()
+    neo4j_session.run("MERGE (a:AWSAccount{id:$AccountId})", AccountId=AWS_ACCOUNT_ID)
+
+    # Act
+    sync_users(neo4j_session, boto3_session, AWS_ACCOUNT_ID, AWS_UPDATE_TAG, PARAMS)
+
+    # Assert that we create policies with expected values for ids.
+    result = neo4j_session.run(
+        """
+        MATCH (user:AWSUser)-[:POLICY]->(pol:AWSPolicy) RETURN user.arn, pol.id;
+        """,
+    )
+
+    # Define the relationships we expect in terms of role ARN and principal ARN.
+    expected = {
+        ('arn:aws:iam::1234:user/user1', 'arn:aws:iam::1234:policy/user1-user-policy'),
+        ('arn:aws:iam::1234:user/user1', 'arn:aws:iam::aws:policy/AmazonS3FullAccess'),
+        ('arn:aws:iam::1234:user/user1', 'arn:aws:iam::aws:policy/AWSLambda_FullAccess'),
+        ('arn:aws:iam::1234:user/user3', 'arn:aws:iam::aws:policy/AdministratorAccess'),
+    }
+    actual = {
+        (r['user.arn'], r['pol.id']) for r in result
+    }
+    # Compare our actual results to our expected results.
+    assert actual == expected
diff --git a/tests/unit/cartography/intel/aws/iam/__init__.py b/tests/unit/cartography/intel/aws/iam/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/cartography/intel/aws/test_iam.py b/tests/unit/cartography/intel/aws/iam/test_iam.py
similarity index 100%
rename from tests/unit/cartography/intel/aws/test_iam.py
rename to tests/unit/cartography/intel/aws/iam/test_iam.py
diff --git a/tests/unit/cartography/intel/aws/iam/test_principal_policies.py b/tests/unit/cartography/intel/aws/iam/test_principal_policies.py
new file mode 100644
index 00000000..ebd6ce78
--- /dev/null
+++ b/tests/unit/cartography/intel/aws/iam/test_principal_policies.py
@@ -0,0 +1,61 @@
+from unittest import mock
+from unittest.mock import call
+from unittest.mock import MagicMock
+
+import cartography.intel.aws.iam
+from cartography.intel.aws.iam import PolicyType
+from cartography.intel.aws.iam import sync_user_managed_policies
+from tests.data.aws.iam.user_policies import GET_USER_LIST_DATA
+from tests.data.aws.iam.user_policies import GET_USER_MANAGED_POLS_SAMPLE
+
+AWS_UPDATE_TAG = 111111
+
+
+@mock.patch.object(cartography.intel.aws.iam, 'load_policy')
+@mock.patch.object(cartography.intel.aws.iam, 'get_user_managed_policy_data', return_value=GET_USER_MANAGED_POLS_SAMPLE)
+def test_sync_user_managed_policies(mock_get_user_pols: MagicMock, mock_load_pol: MagicMock):
+    # Arrange
+    boto3_session = mock.MagicMock()
+    neo4j_session = mock.MagicMock()
+
+    # Act
+    sync_user_managed_policies(boto3_session, GET_USER_LIST_DATA, neo4j_session, AWS_UPDATE_TAG)
+
+    # Assert that we attempt to create policies with expected values for ids.
+    mock_load_pol.assert_has_calls(
+        [
+            call(
+                neo4j_session,
+                'arn:aws:iam::1234:policy/user1-user-policy',
+                'user1-user-policy',
+                PolicyType.managed.value,
+                'arn:aws:iam::1234:user/user1',
+                AWS_UPDATE_TAG,
+            ),
+            call(
+                neo4j_session,
+                'arn:aws:iam::aws:policy/AmazonS3FullAccess',
+                'AmazonS3FullAccess',
+                PolicyType.managed.value,
+                'arn:aws:iam::1234:user/user1',
+                AWS_UPDATE_TAG,
+            ),
+            call(
+                neo4j_session,
+                'arn:aws:iam::aws:policy/AWSLambda_FullAccess',
+                'AWSLambda_FullAccess',
+                PolicyType.managed.value,
+                'arn:aws:iam::1234:user/user1',
+                AWS_UPDATE_TAG,
+            ),
+            call(
+                neo4j_session,
+                'arn:aws:iam::aws:policy/AdministratorAccess',
+                'AdministratorAccess',
+                PolicyType.managed.value,
+                'arn:aws:iam::1234:user/user3',
+                AWS_UPDATE_TAG,
+            ),
+        ],
+        any_order=False,
+    )