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.
Role trust policies#
TRUSTS_AWS_PRINCIPAL connects an AWSRole to each principal that its trust
policy allows to assume it. The relationship exists only where an Allow
statement grants that principal an assume-role action it can call, and no
Deny without a Condition takes that action away. A Deny with a
Condition is evaluated at request time, so it does not remove the
relationship.
AWS evaluates the Condition blocks of a trust policy at request time, so
Cartography records them instead of resolving them. has_condition is false
when at least one statement grants the trust without a Condition. Otherwise,
condition_keys lists the condition keys involved and conditions holds the
raw Condition blocks as a JSON string.
has_external_id is always set. It is true only when every statement that
grants the trust requires the caller to pass an sts:ExternalId, the value a
third party presents to show which customer it is acting for. Conditions that
still match without one, such as StringEqualsIfExists or StringNotEquals,
do not count. Only sts:AssumeRole accepts an external ID, so the property is
relevant to trusts in other AWS accounts.
Find roles that an AWS account outside the sync scope can assume without an external ID, which leaves them open to the confused deputy problem:
MATCH (a:AWSAccount {inscope: true})-[:RESOURCE]->(role:AWSRole)
-[trust:TRUSTS_AWS_PRINCIPAL]->(p:AWSPrincipal)<-[:RESOURCE]-(ext:AWSAccount)
WHERE coalesce(ext.inscope, false) <> true
AND trust.has_external_id = false
RETURN role.arn, p.arn, trust.conditions
Find roles that trust GitHub Actions without a condition on the sub claim,
which identifies the repository and workflow context, such as a branch or tag ref,
an environment, or a pull request:
MATCH (role:AWSRole)-[trust:TRUSTS_AWS_PRINCIPAL]->(idp:AWSFederatedPrincipal)
WHERE idp.arn ENDS WITH ':oidc-provider/token.actions.githubusercontent.com'
AND NOT 'token.actions.githubusercontent.com:sub' IN trust.condition_keys
RETURN role.arn, trust.has_condition, trust.conditions
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.