Skip to content
MINH VO A working notebook
by an engineer in Vietnam
Foundation10 min read

An IAM role is assumed by a principal for a limited session

Read trust, caller and session policies separately, then run a local model that distinguishes resource permission limits from a temporary session expiration.

Miniature conveyor line with three stations and a curved side track

An IAM role is a reusable identity with permissions. A role session is one temporary use of that identity. Confusing them leads to two common mistakes: treating a trust policy as permission to read data, or treating a successful role assumption as permission to perform every later request.

This article is for a developer learning cross-account AWS access. You should be comfortable reading JSON and distinguishing an action from a resource identifier. We will read four small policy documents, then run a local Python model that separates assumption, permission limits and expiration. It creates no AWS credentials, contacts no AWS endpoint and performs no operation on the example bucket.

Name the caller, role and session separately

The IAM roles guide explains that a role has no ordinary long-term password or access keys of its own. An authorized principal assumes it and receives temporary credentials for a session. The role remains available for later sessions after one session expires.

Our declared caller is the ci-reader role in account 111122223333. Its job needs the report-reader role in account 444455556666. These example account numbers and resource names identify the relationships; they are not a deployment configuration for an existing account. In a real request, the caller uses valid credentials. Merely knowing or writing its ARN does not authenticate as that principal.

The target role ARN identifies the role being requested. The assumed-role ARN identifies the session principal resulting from assumption. The latter includes a session name. According to the AssumeRole API reference, that name can appear in the resource owner’s logs. Give it a useful operational meaning, but do not treat an unconstrained caller-supplied name as independent proof of a person’s identity.

Cross-account assumption has two sides

The target role’s trust policy names who may assume it. Here it names the specific caller role and permits the assumption action. It does not contain an S3 object-read permission. The caller’s identity policy separately allows that caller to request the target role. AWS’s cross-account evaluation reference requires both account evaluations to allow the request; other applicable limits can still deny it.

This two-sided explanation is deliberately about different accounts. Do not convert it into a universal rule that every same-account trust arrangement needs the same extra grant. Resource-based grants within one account have additional evaluation rules. Reading the account boundary first prevents an apparently contradictory example from becoming a misleading troubleshooting rule.

Jonathan Jenkyn and Liam Wadman’s November 3, 2022 trust-policy article, with a June 2023 clarification, distinguishes trust, identity policies and permission boundaries. Its specific-role example also explains why trusting an account ARN ending in :root is broader than selecting one named role. That account principal does not mean only the account’s root user. Our example selects one role so the intended relationship remains visible.

Two account-side assumption gates produce a role session, whose resource requests still require the role and session permission sets.View full-size image ↗

After assumption, requests using the returned credentials use the role session’s permissions. They do not automatically combine the original caller’s service permissions with the target role’s service permissions. The original credential source is not magically destroyed; the application must actually select the intended credentials for its next client request.

Read the four documents as four different jobs

The Python block below groups four JSON documents under local labels. That outer object is an exercise file, not one IAM policy to submit. trust belongs on the target role. caller represents the caller’s permission to assume it. role allows reading objects A and B. session asks to restrict this particular session to A and C.

The important result is A alone. The session policy cannot add C when the role’s identity policy never grants C. Equally, the role granting B does not bypass the session restriction that leaves B out. The API reference describes the intersection of these permission sets. This comparison assumes no separate resource-policy grant, explicit deny, organization policy, permission boundary or condition changes the result; it is not the complete IAM evaluation algorithm.

Save the code as role_session_lab.py and run python3 -I role_session_lab.py. It was checked with Python 3.12. The small parser accepts only the exact allow-only policy shape used here. It rejects wildcards in actions and resources, along with additional fields. The trust principal is compared literally with the supplied caller; the code does not implement IAM wildcard matching for principals. It is not an IAM simulator, an authentication implementation or an AWS policy validator.

The model uses a fixed permission snapshot and integer seconds on a fictional clock. The role session starts at second 100, lasts 900 seconds and expires at 1000. Its string ARN is only a label; no access key, secret key or session token is generated. The printed refusal names explain this program’s gates and are not AWS error codes or a promise about AWS’s diagnostic ordering.

from dataclasses import dataclass
import json

# Four separate policy documents, grouped only for this local exercise.
policies = json.loads('''{
  "trust": {
    "Version": "2012-10-17",
    "Statement": [{"Effect": "Allow", "Action": "sts:AssumeRole",
      "Principal": {"AWS": "arn:aws:iam::111122223333:role/ci-reader"}}]
  },
  "caller": {
    "Version": "2012-10-17",
    "Statement": [{"Effect": "Allow", "Action": "sts:AssumeRole",
      "Resource": ["arn:aws:iam::444455556666:role/report-reader"]}]
  },
  "role": {
    "Version": "2012-10-17",
    "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
      "Resource": ["arn:aws:s3:::example-reports/a.csv",
                   "arn:aws:s3:::example-reports/b.csv"]}]
  },
  "session": {
    "Version": "2012-10-17",
    "Statement": [{"Effect": "Allow", "Action": "s3:GetObject",
      "Resource": ["arn:aws:s3:::example-reports/a.csv",
                   "arn:aws:s3:::example-reports/c.csv"]}]
  }
}''')
CALLER = 'arn:aws:iam::111122223333:role/ci-reader'
TARGET = 'arn:aws:iam::444455556666:role/report-reader'
PREFIX = 'arn:aws:s3:::example-reports/'
READ = 's3:GetObject'

def one_allow(document, fields):
    if (set(document) != {'Version', 'Statement'} or
            document['Version'] != '2012-10-17' or
            not isinstance(document['Statement'], list) or
            len(document['Statement']) != 1):
        raise ValueError('outside the teaching subset')
    statement = document['Statement'][0]
    if set(statement) != fields or statement['Effect'] != 'Allow':
        raise ValueError('outside the teaching subset')
    return statement

def grants(document):
    s = one_allow(document, {'Effect', 'Action', 'Resource'})
    action, resources = s['Action'], s['Resource']
    if (not isinstance(action, str) or not isinstance(resources, list)
            or not resources or any(not isinstance(r, str) for r in resources)
            or any('*' in x or '?' in x for x in [action, *resources])):
        raise ValueError('only exact action/resource pairs are supported')
    return frozenset((action, r) for r in resources)

@dataclass(frozen=True)
class SessionModel:
    arn: str
    issued: int
    expires: int
    role_grants: frozenset
    session_grants: frozenset

def assume(caller, caller_policy, duration=900, issued=100):
    trust = one_allow(policies['trust'], {'Effect', 'Action', 'Principal'})
    if trust['Action'] != 'sts:AssumeRole' or trust['Principal'] != {'AWS': caller}:
        raise PermissionError('trust gate')
    if caller_policy is None or ('sts:AssumeRole', TARGET) not in grants(caller_policy):
        raise PermissionError('caller permission gate')
    # The declared role maximum is 3600; this role-to-role example is also chained.
    if type(duration) is not int or not 900 <= duration <= 3600:
        raise ValueError('duration outside this example')
    return SessionModel(
        'arn:aws:sts::444455556666:assumed-role/report-reader/lab-01',
        issued, issued + duration, grants(policies['role']), grants(policies['session']))

def request(session, action, resource, now):
    if now < session.issued:
        return 'not-yet-issued'
    if now >= session.expires:
        return 'expired'
    if (action, resource) not in session.role_grants:
        return 'outside-role'
    if (action, resource) not in session.session_grants:
        return 'outside-session'
    return 'allowed-in-model'

s = assume(CALLER, policies['caller'])
assert s.expires == 1000
print('local session label:', s.arn)
cases = [
    ('a.csv', READ, 100, 'allowed-in-model'),
    ('b.csv', READ, 100, 'outside-session'),
    ('c.csv', READ, 100, 'outside-role'),
    ('a.csv', 's3:PutObject', 100, 'outside-role'),
    ('a.csv', READ, 999, 'allowed-in-model'),
    ('a.csv', READ, 1000, 'expired'),
]
for name, action, now, expected in cases:
    actual = request(s, action, PREFIX + name, now)
    assert actual == expected
    print(name, action, now, actual)
for caller, policy, expected in [
    ('arn:aws:iam::111122223333:role/other', policies['caller'], 'trust gate'),
    (CALLER, None, 'caller permission gate'),
]:
    try:
        assume(caller, policy)
    except PermissionError as error:
        assert str(error) == expected
        print('assumption refused:', error)
    else:
        raise AssertionError('expected refusal')
fresh = assume(CALLER, policies['caller'], issued=1000)
assert request(fresh, READ, PREFIX + 'a.csv', 1000) == 'allowed-in-model'
assert request(s, READ, PREFIX + 'a.csv', 1000) == 'expired'
print('new local session expires:', fresh.expires, '; old one stays expired')

A valid session can still lack the requested permission

The first six rows distinguish three questions: is this local session within its lifetime, does the role permit this action on this object, and does the session restriction retain that permission? Reading A succeeds. Reading B fails the session restriction. Reading C fails the role grant even though C appears in the session document. Writing A also fails because a read permission does not include the write action.

The two assumption failures occur earlier. A different caller does not match this trust relationship. The trusted caller without its cross-account assumption grant also fails. Neither failure is repaired by adding more S3 object permissions to the target role, because those permissions concern the later resource request.

The final comparison uses the same action and object just before and exactly at expiration. Second 999 passes; second 1000 fails. A newly issued local session can pass at 1000 while the old one stays expired. In real AWS, acquiring replacement credentials is a new authorized operation, not an extension of the expired credentials’ lifetime.

The local model allows only the overlap at object A; six request rows distinguish permission restrictions from the expiration boundary.View full-size image ↗

Temporary credentials still need lifecycle management

AWS temporary credentials contain an access key ID, secret access key and session token. The temporary-credential guide requires the token when using those credentials and explains that expired credentials cannot be extended. Copying only the two key fields into a different process does not transfer a complete temporary credential set.

A suitable SDK credential provider can acquire replacement credentials before the old set expires. That is different from copying a one-time result into fixed application configuration and assuming it renews itself. Provider behavior depends on the SDK and credential source; this article does not execute a provider or promise that every manually constructed client refreshes automatically.

For AssumeRole, the documented requested duration starts at 900 seconds and is limited by the role’s configured maximum. Role chaining has an additional one-hour ceiling. Our caller is already a role and our declared maximum is one hour, so the model accepts durations from 900 through 3600. Raising a role maximum would not remove the chaining ceiling. Short sessions reduce the lifetime of a leaked credential set, but require a reliable replacement path for long-running work.

Expiration is also separate from revocation. The role-session revocation guide describes a deny policy conditioned on when the session was issued. That is an authorization change, not a way to edit a credential’s original expiration. Our fixed snapshot does not model policy propagation, existing-session permission changes or revocation; its lifetime check must not be used to predict all behavior after an IAM policy edit.

Change one gate and predict the result

First, add B to the session policy. A and B should now be readable within the session lifetime, while C remains outside the role grant. Then remove C from the session policy. The effective permission set is unchanged, which demonstrates why extra requested permissions are not necessarily effective permissions.

Next, try durations of 899, 900, 3600 and 3601 seconds. The middle two pass this declared duration rule; the outer two raise an error. Changing the local issue time changes expiration by the same amount. It does not repair a missing assumption grant or add a resource permission.

Finally, add a Condition field or wildcard resource to a copied document. The teaching parser should refuse the unsupported policy shape. A real diagnosis must inspect those conditions, all applicable policies, the actual credential source and the request’s action/resource together. This model helps separate those questions; it cannot certify that a production request will succeed.

Sources & further reading

  1. AWS: IAM roles
  2. AWS: AssumeRole API
  3. AWS: Cross-account policy evaluation
  4. AWS: Use temporary credentials
  5. Jonathan Jenkyn and Liam Wadman: Trust policies, 2022
  6. AWS: Revoke role sessions
← Back to the journal
All notes

Illustration

100%