AWS Identity and Access Investigations#

Cartography distinguishes configured permission from observed role use. This matters for IAM and AWS Identity Center investigations because a principal can be permitted to assume many roles while using only a subset.

IAM policy scope and federation#

Inline policies belong to one IAM principal and are scoped through that principal’s AWS account. Managed policies are reusable objects and are not attached to one AWSAccount; follow their attachment relationships to users, groups, and roles when account context is required.

Federated principals are discovered from IAM role trust policies. Cartography does not create a federated principal solely because it exists in an external identity provider.

CloudFormation execution permissions#

CAN_EXEC indicates that a principal is permitted to call cloudformation:UpdateStack on a stack. This can become a privilege-escalation path when the stack specifies a role_arn, because CloudFormation performs the update with that execution role.

When role_arn is absent, CloudFormation uses the caller’s permissions. Treating every CAN_EXEC relationship as privilege escalation would therefore overstate access.

CloudTrail role-assumption evidence#

CloudTrail management events produce relationships that summarize observed role assumptions during the configured lookback window.

Standard AssumeRole events connect an AWS principal to an IAM role:

(:AWSPrincipal)-[:ASSUMED_ROLE {
  times_used,
  first_seen,
  last_seen,
  lastused
}]->(:AWSRole)

SAML assumptions connect an Identity Center user to the role that was used:

(:AWSSSOUser)-[:ASSUMED_ROLE_WITH_SAML {
  times_used,
  first_seen_in_time_window,
  last_used,
  lastupdated
}]->(:AWSRole)

GitHub Actions web-identity assumptions use the same window-oriented property names:

(:GitHubRepository)-[:ASSUMED_ROLE_WITH_WEB_IDENTITY {
  times_used,
  first_seen_in_time_window,
  last_used,
  lastupdated
}]->(:AWSRole)

Generic web-identity providers are not currently modeled by that relationship. Take care with the property names: the standard, SAML, and web-identity relationships do not use one common timestamp vocabulary.

Identity Center assignments#

AWSIdentityCenter owns AWSPermissionSet nodes. A permission set creates an AWSRole in each account where it is assigned:

(:AWSIdentityCenter)-[:HAS_PERMISSION_SET]->(:AWSPermissionSet)
(:AWSPermissionSet)-[:ASSIGNED_TO_ROLE]->(:AWSRole)

Users and groups receive HAS_ROLE summary relationships to permission sets. These show that the permission set is available, but they do not identify the specific account assignment. Account-specific role access is represented by ALLOWED_BY:

(:AWSSSOUser)-[:HAS_ROLE]->(:AWSPermissionSet)
(:AWSSSOGroup)-[:HAS_ROLE]->(:AWSPermissionSet)
(:AWSRole)-[:ALLOWED_BY]->(:AWSSSOUser)
(:AWSRole)-[:ALLOWED_BY]->(:AWSSSOGroup)

AWSSSOUser access includes direct assignments and assignments inherited through AWSSSOGroup membership. The AWS Identity Center list_account_assignments_for_principal API resolves group membership server-side. As a result, a user can have direct HAS_ROLE and ALLOWED_BY edges even when the underlying assignment was made only to a group. Keep the MEMBER_OF and group assignment paths when the assignment source matters.

Find a user’s permitted account roles and the permission sets that created them:

MATCH (user:AWSSSOUser {id: $user_id})<-[:ALLOWED_BY]-(role:AWSRole)
MATCH (permission_set:AWSPermissionSet)-[:ASSIGNED_TO_ROLE]->(role)
MATCH (account:AWSAccount)-[:RESOURCE]->(role)
RETURN account.id, account.name, role.arn,
       permission_set.name, permission_set.arn
ORDER BY account.name, permission_set.name

Show group paths that can explain inherited access:

MATCH (user:AWSSSOUser {id: $user_id})-[:MEMBER_OF]->(group:AWSSSOGroup)
MATCH (group)-[:HAS_ROLE]->(permission_set:AWSPermissionSet)
MATCH (permission_set)-[:ASSIGNED_TO_ROLE]->(role:AWSRole)
WHERE (role)-[:ALLOWED_BY]->(user)
RETURN group.display_name, permission_set.name, role.arn
ORDER BY group.display_name, permission_set.name

Permitted access versus actual use#

ALLOWED_BY represents permitted account-role access. CloudTrail-derived ASSUMED_ROLE_WITH_SAML represents actual use. Compare them to find permitted roles that were not observed during the lookback window:

MATCH (user:AWSSSOUser {id: $user_id})<-[:ALLOWED_BY]-(role:AWSRole)
WHERE NOT (user)-[:ASSUMED_ROLE_WITH_SAML]->(role)
MATCH (account:AWSAccount)-[:RESOURCE]->(role)
RETURN account.id, role.arn
ORDER BY account.id, role.arn

The absence of an observed edge is not proof that a role has never been used. It is limited by CloudTrail availability, event coverage, and the configured lookback window.

Summarize observed use:

MATCH (user:AWSSSOUser)-[usage:ASSUMED_ROLE_WITH_SAML]->(role:AWSRole)
RETURN user.user_name, role.arn, usage.times_used,
       usage.first_seen_in_time_window, usage.last_used
ORDER BY usage.last_used DESC

Okta and Entra federation#

Federated identity paths connect external identity providers to Identity Center users:

(:OktaUser)-[:CAN_ASSUME_IDENTITY]->(:AWSSSOUser)
(:UserAccount)-[:CAN_ASSUME_IDENTITY]->(:AWSSSOUser)
(:EntraUser)-[:CAN_SIGN_ON_TO]->(:AWSSSOUser)
(:EntraServicePrincipal)-[:FEDERATES_TO]->(:AWSIdentityCenter)

For an Okta user, combine federation, permitted access, and actual use:

MATCH (external:OktaUser)-[:CAN_ASSUME_IDENTITY]->(user:AWSSSOUser)
OPTIONAL MATCH (user)<-[:ALLOWED_BY]-(permitted:AWSRole)
OPTIONAL MATCH (user)-[usage:ASSUMED_ROLE_WITH_SAML]->(used:AWSRole)
RETURN external.login, user.user_name,
       collect(DISTINCT permitted.arn) AS permitted_roles,
       collect(DISTINCT {
         role: used.arn,
         times_used: usage.times_used,
         last_used: usage.last_used
       }) AS observed_role_use

For Entra, begin with EntraUser-[:CAN_SIGN_ON_TO]->AWSSSOUser and apply the same permission and usage patterns.