In some AWS automation, a broker produces valid temporary credentials but downstream access checks still fail on certain resources. For example, a session from role R, whose base policy allows iam:GetRole on roles A and B, might still be denied access to Role C, even if the inline session policy explicitly allows C. This raises the exact question: When you call AssumeRole with an inline session policy, what permissions does the resulting session actually have? Are they simply the intersection of the role’s base permissions and the session policy, or can the session policy “add back” a permission that R’s base policy omitted?
We’ll answer this by testing three STS sessions from R against three target roles A, B, C in one nonproduction account. We restrict to read-only iam:GetRole calls. The provider identity (source profile) assumes R; the audit identity (audit profile) only reads configuration. We produce one session with no inline policy (BASE), one with a policy allowing only Role A (ONLY_A), and one allowing A and C (A_PLUS_C). We then call GetRole on A, B, C for each session, yielding a 3×3 result matrix. The examples provide driver excerpts, an oracle of expected outcomes and evidence reconciliation guidelines. A complete matching run would show that session policies restrict the permissions available from R’s base policy.
This authorization experiment builds on enterprise IAM and least-privilege foundations.
Define the permission claim before issuing credentials
We frame the test as an identity-policy authorization question, not a resource-policy or network issue. The broker’s log shows the session creation succeeded (it has credentials), but subsequent calls to iam:GetRole on different roles give different results. We must separate three concepts: issuance (we obtained a valid STS session from role R), identity (what AWS sees as the caller’s identity via GetCallerIdentity), and authorization (what GetRole calls are allowed). These are distinct. For example, success of GetCallerIdentity only confirms our session is valid; it does not imply we have permission to list roles.
We fix the scope: R has an identity-based policy we control (the “base” policy), and we compare three session cases (BASE/ONLY_A/A_PLUS_C). The target actions are always iam:GetRole on specific Role ARNs; we are not assuming roles A/B/C themselves. There is no S3, no cross-account, no permissions boundaries. We trust that R’s assume-role conditions (trust, MFA, source ID) are met.
For cloud/security engineers and session-broker maintainers who know IAM JSON, this provides a precise, repeatable check. The audit will either approve that the broker’s session policy logic is correctly limiting R’s permissions, repair the configuration if something is amiss, or hold if the evidence is incomplete or unexpected. This is a narrow test of one action in one account – not an all-clear for the entire environment.
Freeze the account, target roles and audit boundary
We prepare a frozen nonproduction environment. The account/partition and regional STS endpoint are fixed and documented. We use two named AWS CLI profiles (source and audit) with explicit credentials. The audit profile must be able to call IAM APIs and STS GetCallerIdentity in this account, but we do not use it to assume R. The source profile has permission to call sts:AssumeRole on R under its trust policy (and any MFA/external-id requirements). All network/VPC and Organization restrictions that apply must be attested by the owner – we assume the test isn’t blocked by endpoint policies or SCPs.
We enumerate these manifest entries up front: account ID, region/endpoint, profiles, RoleName/ARN/RoleId for R (the source role) and for each target A, B, C. Paths matter: if a role has a path (e.g. role/engineering/A), the ARN must include it. We use exact ARNs (no wildcards). We also record R’s trust policy hash (to detect changes) and all role identities (RoleIds) to detect role recreation. For example, assume R’s inline policy name is BaseGetRole and it’s currently set to:
{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","Action":"iam:GetRole","Resource":[
"<exact-A-ARN>","<exact-B-ARN>"
]}
]}We do not modify R’s policies during the test. This is a lab precondition: R must have exactly that one inline policy, no managed policies, and no permissions boundary. We explicitly trust that isolation: any deviation (extra attachments or a boundary) will be treated as a failure in setup. Before running probes, the audit client (using the audit profile) will call IAM’s ListRolePolicies and ListAttachedRolePolicies on R to ensure exactly one inline and zero managed. It will then call GetRolePolicy on R to fetch that inline JSON. If any attachments or a non-empty boundary are found, the procedure halts (HOLD) because our environment no longer matches the test assumptions.
Separate trust from operation permission
We also note that R’s trust policy (who can assume R) is unrelated to whether our session can call GetRole. The trust policy is a resource-based policy on R that lists allowed principals. Once we have valid session tokens, we’ve satisfied that trust condition. It does not grant any permissions like GetRole on other roles. AWS describes two role policies: the trust policy specifies who can assume the role, and the permissions policy specifies what the role can do. Likewise, roles A/B/C have trust policies about who can assume them, but that has no effect on whether we can call GetRole on them. There are no resource-based policies on A/B/C for the iam:GetRole action. This is purely an identity-based permissions check: does our session’s effective IAM policy allow iam:GetRole on each target?
Inspect every policy that can spoil the comparison
We rigorously audit R’s policies before probing. Using the audit IAM client, we paginate through: ListRolePolicies(RoleName=R) and ListAttachedRolePolicies(RoleName=R). In our simplified fixture, we expect exactly one inline policy named e.g. BaseGetRole. We then call GetRolePolicy to retrieve its JSON, and compute a canonical SHA-256 hash of it (sorted keys, no extra spaces) for tamper evidence. We also record R’s trust policy from GetRole(RoleName=R) and hash it separately. No other identity policies (managed or boundary) should exist; if any are found, we fail (HOLD) rather than assume they have default content. The audit also calls GetRole(RoleName=A), RoleName=B, RoleName=C to confirm each target’s existence and retrieve its RoleId. We don’t need target policies beyond identity. We document the manifest and inventory so that after the test we can re-verify nothing changed. (IAM is eventually consistent; we verify propagation and stable inventory before probing.)
This mirrors good practice for least-privilege reviews and policy composition as taught in AWS security guidance. By freezing the environment and capturing hashes of the relevant documents, we create an audit trail that supports comparison. If R had any unexpected policy, our guide would instruct either repairing it or tagging the run as incomplete.
Declare the nine expected outcomes independently
Before running anything, we state our oracle. The base role policy is the one above (A and B allowed, C not mentioned). The session policies are:
BASE (no session policy): we do not pass the Policy parameter at all. This means the session has whatever R’s base policy allows.
ONLY_A (session policy on A): inline policy JSON allowing iam:GetRole only on target A’s exact ARN.
A_PLUS_C (session policy on A and C): JSON allowing iam:GetRole on the exact ARNs of targets A and C.
We craft these JSON documents as:
// Inline policy on R (base)
{
"Version":"2012-10-17",
"Statement":[
{"Effect":"Allow","Action":"iam:GetRole",
"Resource":["<exact-A-ARN>","<exact-B-ARN>"]}
]
}// ONLY_A session policy
{"Version":"2012-10-17",
"Statement":[{"Effect":"Allow","Action":"iam:GetRole",
"Resource":["<exact-A-ARN>"]}]}// A_PLUS_C session policy
{"Version":"2012-10-17",
"Statement":[{"Effect":"Allow","Action":"iam:GetRole",
"Resource":["<exact-A-ARN>","<exact-C-ARN>"]}]}For these cases, AWS documents the intersection of R’s base permissions and each session policy in its IAM policy guidance and AssumeRole API reference. That yields the 3×3 expected matrix (the oracle):
Session Case | GetRole on A | GetRole on B | GetRole on C |
BASE | ALLOW | ALLOW | DENY |
ONLY_A | ALLOW | DENY | DENY |
A_PLUS_C | ALLOW | DENY | DENY |
In words: with no session policy (BASE), R’s policy allows A and B (so those calls should succeed) and denies C. With ONLY_A, the session policy further forbids B and C, so only A works. With A_PLUS_C, the session policy would allow C, but the base policy does not – so C should still be denied. We stress: the session policy cannot grant GetRole on C because R’s base policy never allowed it.
Preserve the omitted-policy baseline
BASE must be implemented by omitting the Policy parameter entirely. Passing an empty JSON {} or similar is not the same – AWS treats that as an error. The AWS docs say the inline policy is optional; in this fixture, omission leaves R’s base permission scope unchanged. We won’t send an empty string or malformed JSON as the base case.
Our expected table above is our authority (the “oracle”) before any run. This separate declaration ensures we don’t subconsciously tune toward a desired outcome. We’ll require that the observed (Case,Target) entries exactly match these nine predicates. Any deviation (fewer or extra rows, or a missing ALLOW/DENY where we expected the opposite) will flag a failure or hold.
Bind each new session to its exact request
For each of the three cases, we call AssumeRole from the source profile with these parameters: R’s RoleArn and a unique RoleSessionName. We use distinct names per case (e.g. including run IDs and case labels) to avoid confusion. We include DurationSeconds=900 (the documented minimum is 900 and we use the shortest valid session for simplicity). We do not skip any mandatory trust conditions – for example, if R’s trust requires MFA or an ExternalId, we supply those exactly.
For BASE, the Policy parameter is simply omitted. For ONLY_A and A_PLUS_C, we JSON-encode the respective session policy (from above) and pass it as Policy=.... AWS enforces a 2,048-character session policy limit for this inline-only fixture. A failure to assume (e.g. MalformedPolicyDocument or trust violation) is a setup failure, not an expected DENY. We will log such failures and abort the run (marked INCOMPLETE) since we can’t proceed otherwise.
Upon success, AssumeRole returns Credentials (AccessKeyId, SecretAccessKey, SessionToken) and an AssumedRoleUser with an ARN and an AssumedRoleId. We record the returned session ARN (it contains R’s name and our RoleSessionName) and expiration. We never expose raw keys in logs.
Using those credentials, we immediately call STS GetCallerIdentity via a new temporary STS client. We assert:
The returned Account ID matches our manifest’s account (sanity check).
The returned ARN equals AssumedRoleUser.Arn.
The returned UserId equals AssumedRoleUser.AssumedRoleId.
The ARN identifies the expected partition, account, role name and session name. The UserId prefix before the colon must match R’s RoleId.
These checks establish that the credentials belong to the intended session of R. GetCallerIdentity requires no IAM permission; its success identifies the caller without establishing permission for GetRole. Token, network or service errors can still prevent this check from completing. Any identity mismatch stops the case immediately.
Finally, we use the returned credentials to create an isolated IAM client (with its own boto3 Session) to probe each target. This client is bound to our session by passing the three credential values explicitly (aws_access_key_id, aws_secret_access_key, aws_session_token). We pin the intended region and the correct service-specific endpoints for STS and IAM.
Build the isolated-client probe driver
The core of our workflow is a standalone Python/Boto3 driver. It takes the manifest as input, performs the audits and probes as described, and outputs a report with safe metadata. The following excerpts show the essential parts; the surrounding validation, file output and cleanup must be completed before execution:
import hashlib, json
from datetime import datetime, timezone
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
# Stable client config (no retries, short timeouts)
CFG = Config(connect_timeout=5, read_timeout=10,
retries={"mode": "standard", "total_max_attempts": 1})
def canonical(value):
"""Produce a compact sorted JSON string for hashing."""
return json.dumps(value, sort_keys=True, separators=(",", ":"))
def session_policy(arns):
"""Construct the session policy dict to Allow GetRole on given ARNs."""
return {"Version": "2012-10-17", "Statement": [
{"Effect": "Allow", "Action": "iam:GetRole", "Resource": arns}
]}
def client_for(credentials, service, region, endpoint=None):
"""Create an isolated boto3 client with given credentials."""
sess = boto3.Session(
aws_access_key_id=credentials["AccessKeyId"],
aws_secret_access_key=credentials["SecretAccessKey"],
aws_session_token=credentials["SessionToken"],
region_name=region
)
args = {"config": CFG}
if endpoint:
args["endpoint_url"] = endpoint
return sess.client(service, **args)
def probe(iam, case, target, declared):
"""
Perform iam:GetRole for a target RoleName using given IAM client.
Returns a result row dictionary.
"""
row = {"case": case, "target": target, "action": "iam:GetRole",
"time": datetime.now(timezone.utc).isoformat(),
"outcome": "UNKNOWN"}
try:
resp = iam.get_role(RoleName=declared["RoleName"])
meta = resp["ResponseMetadata"]
role = resp["Role"]
# Compare returned identity to declared ARN and RoleId
row.update(http_status=meta["HTTPStatusCode"],
request_id=meta.get("RequestId"),
arn=role["Arn"], role_id=role["RoleId"])
if (role["Arn"], role["RoleId"]) == (declared["Arn"], declared["RoleId"]):
row["outcome"] = "ALLOW"
else:
row["error_code"] = "TARGET_IDENTITY_MISMATCH"
except ClientError as exc:
meta = exc.response.get("ResponseMetadata", {})
code = exc.response.get("Error", {}).get("Code", "")
status = meta.get("HTTPStatusCode")
row.update(http_status=status, error_code=code,
request_id=meta.get("RequestId"))
if status == 403 and code in {"AccessDenied", "AccessDeniedException"}:
row["outcome"] = "DENY"
except Exception as exc:
row["error_type"] = type(exc).__name__
return rowThe above probe function catches the get_role response or any ClientError. We record HTTP status, AWS error code, and if the call succeeded (200 OK), we verify the returned role’s ARN/RoleId match the expected target. Only then do we mark ALLOW. A 403 status with code AccessDenied or AccessDeniedException yields DENY. All other errors (404 NoSuchEntity, token expiry, throttling, etc.) remain UNKNOWN/HOLD. We do not parse any error message string or assume its cause beyond the exact code.
Next, the main driver sequence is roughly:
# (1) Load manifest (JSON or other format) containing account, region, profiles, R, A, B, C info.
manifest = load_manifest("manifest.json")
# (2) Audit R's setup (using boto3.Session(profile_name=manifest['audit_profile']))
audit_session = boto3.Session(profile_name=manifest["audit_profile"])
iam_audit = audit_session.client('iam', region_name=manifest["region"])
sts_audit = audit_session.client('sts', region_name=manifest["region"])
# (2a) Verify account context
acct = sts_audit.get_caller_identity()["Account"]
assert acct == manifest["Account"], "Audit profile in wrong account"
# (2b) List and fetch R's policies
rlp = iam_audit.list_role_policies(RoleName=manifest["R_RoleName"])
arlp = iam_audit.list_attached_role_policies(RoleName=manifest["R_RoleName"])
# Expect exactly one inline, zero attached
if len(rlp.get("PolicyNames", [])) != 1 or arlp.get("AttachedPolicies", []):
raise Exception("Role R policies not in expected baseline (HOLD)")
policy_name = rlp["PolicyNames"][0]
inline_doc = iam_audit.get_role_policy(RoleName=manifest["R_RoleName"], PolicyName=policy_name)["PolicyDocument"]
trust_doc = iam_audit.get_role(RoleName=manifest["R_RoleName"])["Role"]["AssumeRolePolicyDocument"]
# (2c) Canonicalize and hash the inline and trust policy for evidence
hash_inline = hashlib.sha256(canonical(inline_doc).encode()).hexdigest()
hash_trust = hashlib.sha256(canonical(trust_doc).encode()).hexdigest()
# (3) Pre-fetch target identities to detect drift
targets = {"A":manifest, "B":manifest, "C":manifest}
for tgt in ["A","B","C"]:
resp = iam_audit.get_role(RoleName=manifest[f"{tgt}_RoleName"])["Role"]
assert resp["Arn"] == manifest[f"{tgt}_Arn"] and resp["RoleId"] == manifest[f"{tgt}_RoleId"], \
f"Target {tgt} ARN/ID mismatch (HOLD)"
# (4) For each session case, assume and probe
cases = {
"BASE": None,
"ONLY_A": [manifest["A_ARN"]],
"A_PLUS_C": [manifest["A_ARN"], manifest["C_ARN"]]
}
results = []
sts_source = boto3.Session(profile_name=manifest["source_profile"]).client('sts', region_name=manifest["region"])
for case, allowed_arns in cases.items():
params = {"RoleArn": manifest["R_ARN"],
"RoleSessionName": f"test-{case}-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}",
"DurationSeconds": 900}
if allowed_arns is not None:
policy_dict = session_policy(allowed_arns)
params["Policy"] = json.dumps(policy_dict)
results.append({"case": case, "policy_hash": hashlib.sha256(
canonical(policy_dict).encode()).hexdigest()})
resp = sts_source.assume_role(**params) # Could raise exception
creds = resp["Credentials"]
assumed_arn = resp["AssumedRoleUser"]["Arn"]
# (4a) Verify identity via GetCallerIdentity
sts_tmp = client_for(creds, 'sts', manifest["region"])
ci = sts_tmp.get_caller_identity()
if (ci["Account"] != manifest["Account"]
or ci["Arn"] != assumed_arn
or ci["UserId"] != resp["AssumedRoleUser"]["AssumedRoleId"]):
raise Exception(f"Identity check failed for case {case}")
# (4b) Create IAM client for this session and probe each target
iam_tmp = client_for(creds, 'iam', manifest["region"])
for tgt in ["A","B","C"]:
declared = {"RoleName": manifest[f"{tgt}_RoleName"],
"Arn": manifest[f"{tgt}_Arn"],
"RoleId": manifest[f"{tgt}_RoleId"]}
row = probe(iam_tmp, case, tgt, declared)
results.append(row)The complete workflow must ensure:
The source and audit profiles are used only for their intended roles (source only for assume, audit only for config reads).
Each temporary client uses all three credential components via client_for.
We compute and record a SHA-256 hash of each session policy document (or mark “OMITTED” for BASE).
We promptly run the probes within the session’s validity. Expired or invalid credentials must be recorded as outcome="UNKNOWN".
At the end, we could further verify invariants: exactly 9 unique (case,target) rows, no duplicates, all outcomes matching the expected matrix, three distinct session identities (ARNs). We would also re-fetch R’s policies to confirm nothing changed (R’s policy hashes should match pre-run). Only if all these checks pass do we conclude the test is PASS. Any anomaly (mismatched identities, extra errors, missing denies, etc.) is logged as FAIL or HOLD depending on severity.
For an execution, write results to a fresh run directory along with a manifest snapshot and the Python and SDK versions. The snippets and illustrative results here do not report an executed AWS trial. Complete the manifest loader, inventory checks, result reconciliation and output handling before using the excerpts as a runnable driver.
Prove identity without mistaking it for permission
A key subtlety is that GetCallerIdentity itself requires no IAM permissions. A successful response returns the caller’s AWS account, ARN, and user ID, even if an explicit policy denies sts:GetCallerIdentity. Thus a successful GetCallerIdentity only attests that our credentials are valid and whose credentials they are – it does not imply any additional access. We use it purely to bind each session name to a concrete ARN/RoleId and ensure we didn’t slip back to a different profile. Under no circumstances do we interpret it as an “ALLOW” on some privileged action. We explicitly check that the returned ARN includes assumed-role/R/ and that the UserId consists of R’s RoleId followed by a colon and the session name. If this check fails, we don’t even attempt GetRole probes (we would HOLD or FAIL the test).
Run the baseline and retain its denied target
Now we simulate the run results conceptually. For BASE (no session policy), the session’s effective policy should be exactly R’s base policy. We would call GetRole on A, B, C in turn. According to the oracle, A and B calls should succeed (HTTP 200) and return the role’s ARN/RoleId, while C should fail (HTTP 403, AccessDenied).
If the actual BASE run deviates (say GetRole C unexpectedly succeeds, or GetRole A/B gets denied), then something is wrong: maybe R’s inline policy was wrong or some other permission boundary is in place. In that case we would mark HOLD or FAIL and stop; it indicates the test assumptions failed. We do not fix the policy mid-run. (Because IAM changes are eventually consistent, verify propagation and stable inventory before probing. A short delay alone is insufficient.)
A correct baseline run will have one row ("BASE", "A", outcome="ALLOW"), one ("BASE", "B", ALLOW) and one ("BASE", "C", DENY). We would store these results. If the two ALLOW responses have the correct ARNs and RoleIds, we trust the sessions. If, say, the C call returned AccessDenied, we know that came from the policy evaluation path, as expected.
Compare restriction with an attempted extra allowance
Next, we run ONLY_A and A_PLUS_C sessions. For ONLY_A (session policy only lists A), the expected outcome is ALLOW on A, DENY on B, DENY on C. For A_PLUS_C, we expect ALLOW on A, DENY on B, DENY on C (even though the session policy names C, R’s base does not).
We note that AssumeRole for A_PLUS_C should succeed fine – R allows A and B, and the session policy grants A and C. The session token is valid (we get credentials), but that does not mean it has all those permissions. When we actually call GetRole C, the result should still be 403 DENY. This is the core finding: the session policy’s Allow for C doesn’t override the fact that R’s base policy never allowed it.
Both restricted sessions should observe DENY for B, while BASE should allow B. This comparison checks that the session-policy restriction is working. The three independent ALLOWs on A, one from each of BASE, ONLY_A and A_PLUS_C, also check that the probes are functioning. If A_PLUS_C returned ALLOW on C under the verified fixture, the result would contradict the expected permission intersection and the run would fail.
Classify the returned error rather than the HTTP family
In analyzing the DENY cases, we focus on the AWS error code, not just HTTP 403. AWS commonly returns either AccessDenied or AccessDeniedException with a 403 status for lacking permissions. Our driver explicitly checks for those. We never simply catch “any 403” – for example, ExpiredTokenException can also return HTTP 403. If GetRole on C for A_PLUS_C yields something unexpected (say ExpiredToken or network error), we classify it as UNKNOWN/HOLD, not as a confirmation of DENY.
The driver accepts AccessDenied or AccessDeniedException only with HTTP 403 and preserves the exact error-code spelling. IAM common error types distinguish permission failures from other errors that can also return 403. A different code remains a discrepancy to investigate. For example, NoSuchEntity (HTTP 404) or ExpiredTokenException must produce UNKNOWN and HOLD, prompting a fresh check of the inputs and session.
Reconcile complete coverage and unchanged setup
Once all probes have run, we consolidate the evidence. We expect exactly 9 result rows (3 sessions × 3 targets), no duplicates. We verify:
All http_status are 200 or 403 as appropriate (no 4xx/5xx other than DENY).
Every ALLOW row has the returned ARN and RoleId matching the declared target (no TARGET_IDENTITY_MISMATCH).
The three GetCallerIdentity checks produced three distinct assumed-role ARNs (one for each session).
The pre/post inventory of R’s inline policy and trust shows the same hashes (nothing changed during the run).
The following illustrative evidence table uses placeholder hashes and synthetic role identities:
Case | Target | Policy hash | Caller identity (ARN) | Target ARN | Target RoleId | HTTP status | Error code | Outcome |
BASE | A | hash_of_BASE_policy | arn:aws:sts::123456789012:assumed-role/R/session-BASE | arn:aws:iam::123456789012:role/.../A | AAAAAAAAAAAAAAAA | 200 | ALLOW | |
BASE | B | hash_of_BASE_policy | arn:aws:sts::123456789012:assumed-role/R/session-BASE | arn:aws:iam::123456789012:role/.../B | BBBBBBBBBBBBBBBB | 200 | ALLOW | |
BASE | C | hash_of_BASE_policy | arn:aws:sts::123456789012:assumed-role/R/session-BASE | arn:aws:iam::123456789012:role/.../C | CCCCCCCCCCCCCCCC | 403 | AccessDenied | DENY |
ONLY_A | A | hash_of_ONLY_A_policy | arn:aws:sts::123456789012:assumed-role/R/session-ONLY_A | arn:aws:iam::123456789012:role/.../A | AAAAAAAAAAAAAAAA | 200 | ALLOW | |
ONLY_A | B | hash_of_ONLY_A_policy | arn:aws:sts::123456789012:assumed-role/R/session-ONLY_A | arn:aws:iam::123456789012:role/.../B | BBBBBBBBBBBBBBBB | 403 | AccessDenied | DENY |
ONLY_A | C | hash_of_ONLY_A_policy | arn:aws:sts::123456789012:assumed-role/R/session-ONLY_A | arn:aws:iam::123456789012:role/.../C | CCCCCCCCCCCCCCCC | 403 | AccessDenied | DENY |
A_PLUS_C | A | hash_of_A_PLUS_C_policy | arn:aws:sts::123456789012:assumed-role/R/session-A_PLUS_C | arn:aws:iam::123456789012:role/.../A | AAAAAAAAAAAAAAAA | 200 | ALLOW | |
A_PLUS_C | B | hash_of_A_PLUS_C_policy | arn:aws:sts::123456789012:assumed-role/R/session-A_PLUS_C | arn:aws:iam::123456789012:role/.../B | BBBBBBBBBBBBBBBB | 403 | AccessDenied | DENY |
A_PLUS_C | C | hash_of_A_PLUS_C_policy | arn:aws:sts::123456789012:assumed-role/R/session-A_PLUS_C | arn:aws:iam::123456789012:role/.../C | CCCCCCCCCCCCCCCC | 403 | AccessDenied | DENY |
This illustrative example matches the oracle exactly. If an actual run matched this table and the pre/post policy and trust hashes matched, the result would support PASS and approval of the tested configuration. Complete, otherwise valid results that deviate, such as an unexpected ALLOW on C, require investigation and repair. Incomplete evidence, UNKNOWN outcomes or identity mismatches require HOLD.
Explain where the intersection claim stops
For the scoped identity-based permissions on IAM roles, AWS documents a session policy as a further restriction on the role’s own policy. It cannot grant extra actions. For this fixture, when a session policy is supplied and no applicable explicit Deny is present, effective permissions are the intersection of the role policy and session policy.
However, we must be clear: there are other IAM controls not exercised by this test. For example, an AWS Organizations SCP or a permissions boundary on R would also intersect with these permissions – and an explicit Deny in any layer always overrides. Our scope excludes those.
We also note the documented exception: if a resource-based policy explicitly names the assumed session ARN as principal, that policy is evaluated after the session is created and is not limited by an implicit denial in the session policy. Applicable explicit denies still override. In our case, the trust policy on A/B/C does not count as such a resource policy for GetRole – trust policies only govern sts:AssumeRole, not who can call GetRole on them. (IAM roles have no resource policy for reading role metadata.) If we had a hypothetical resource that explicitly allowed arn:aws:sts::<acct>:assumed-role/R/... to do something, that could supply an allow for a permission omitted from the session policy. But we do not attempt any such bypass or generalize to other services in this test. We confine our conclusions strictly to this action (GetRole) in this account.
The failure modes are thus: for a restricted session, an ALLOW requires both the base policy and the session policy to permit it; a DENY could come from the session policy (if it omitted the action) or from the base policy (if it never allowed it) or from an explicit Deny. With complete matching evidence and unchanged audited setup, a DENY on C in all three sessions supports attribution to the absence of C in the base policy and the conclusion that session policies cannot add privileges.
Choose approve, repair or hold from evidence
Finally, we make a scoped decision based on the complete observed matrix and verified execution evidence:
Approve: If all nine results match the oracle and R’s policies/trust are unchanged, then R’s owner can be told “your policy correctly grants only A and B.” The session broker’s inline policies are working as a restriction, not as a hidden grant; we can approve the broker’s contract.
Repair: If the matrix is valid but unexpected – for example, if BASE allowed C or either restricted session allowed B – then something is misconfigured. We would direct the role owner to fix R’s base policy (e.g. remove unintended iam:GetRole on C or restore the declared A/B base permissions). Or if only the broker seems wrong (say ONLY_A accidentally allowed B), we tell the broker owner to fix the session policy. We would not suggest blanket fixes like giving AdministratorAccess. Instead, identify whether R’s policy or the broker’s session policy is at fault.
Hold: If identities drifted, some target was missing, policy attachments were unexpected, or any UNKNOWN error occurred, we stop and gather more info. For example, if R’s policy had a managed policy we didn’t audit, we’d pause the test. If an AWS service error or a 404 (NoSuchEntity) occurred, we double-check inputs. In these cases the lab prerequisites haven’t been fully met, so we can’t draw a conclusion.
In all cases, we attribute responsibilities carefully: R’s base permissions (Allow A/B) belong to R’s owner; the broker’s session policy restrictions belong to the broker owner; and environmental attestation (account setup, endpoints, STS configs) belong to platform/security.
Recover the workflow without pretending to revoke sessions
If the verdict is FAIL or Repair, preserve the evidence logs and suspend affected dependent work. The responsible role or broker owner must review the policy or session-logic change separately. Then rerun the complete test from the start with new session names and credentials, using the same manifest and oracle. Different supplied session-policy documents require newly issued sessions. AWS documents revoking IAM role session permissions through an IAM policy named AWSRevokeOlderSessions, which denies actions for credentials issued before a specified time. That separate procedure is outside this test. No expiry, old-session persistence or revocation behavior is tested here. Closing local clients or ending the script does not revoke issued sessions.
Keep cleanup within the prepared-resource contract
Cleanup is minimal. The script does not create persistent AWS resources. We just close local connections (boto3 clients are usually cleaned up on exit). If we write any files (run logs, JSON output), we leave them in the run directory. We do not delete role R or roles A/B/C – those are owned by others. We also do not auto-remove any AWS roles or policies. (Any role deletion or policy changes should be done by the resource owner as part of normal cleanup once this lab evidence is final.) The point is: this test does not have destructive steps that need reversal.
Assign regression ownership and a reproducible rerun
To make this process repeatable, we define clear triggers for rerun or regression:
Role policy change: If someone alters R’s inline policy (or attaches a new policy or boundary), that potentially changes outcomes. Re-run to verify.
Trust policy change: If R’s trust policy is modified in a way that could revalidate the assumption (e.g. adding a new principal), it doesn’t affect GetRole permissions, but we should record it.
Broker session policy change: If the session JSON generated by the broker is updated (say, A vs A+C changed), re-run to confirm new intersection.
Targets/paths change: If the declared roles, target paths or ARNs change, re-run with updated inputs.
Tooling or AWS update: If we upgrade boto3/botocore or AWS modifies IAM (rare), we should re-verify.
For each rerun, we start a new output directory with a new timestamped run ID, re-sign the manifest, and reuse the exact oracle. Selective re-probing (e.g. just rerun the failed cell) is discouraged; we always do a clean start. In practice, this joins broader operational credential routines: when an identity policy or broker config changes, any session that might be affected should be re-tested. The fix for a FAIL is not to tweak one probe, but to correct the policy and reissue new sessions.
This fits naturally into an organization’s cloud security ownership. The IAM policy owner is responsible for their base policy; the developers of the session broker (or Terraform module) are responsible for correct session policy construction; and the security/platform team maintains the test harness and evidence logs. These operational tasks align with the broader responsibilities of cloud security engineers.
Build the cloud identity foundations behind the test
This exercise illustrates core cloud engineering skills: understanding AWS identities (roles and sessions), writing and hashing IAM policies, using STS calls and the AWS SDK, and producing auditable evidence of permissions. These are exactly the sorts of skills covered in the Refonte Cloud Engineer program, a 3-month, 12–14 hours/week virtual internship focused on AWS/Azure/GCP fundamentals, virtualization, IaC (CloudFormation/Terraform), security, automation and more. (Instructor: Charlotte Smith.) The program includes managing cloud identities and access, giving learners foundations for writing and testing policies like those examined here.
To develop broader cloud identity and access-management skills, consider Refonte’s Cloud Engineer program. The practical issues explored here, including session-policy intersections, evidence-driven debugging and least-privilege enforcement, are part of cloud engineering work. This article complements that training by isolating one operational scenario and explaining the expected audit evidence.
This workflow compares three STS sessions from one IAM role using iam:GetRole on three target roles. A complete run matching the declared oracle would support the scoped conclusion that session policies only remove permissions; they cannot grant an action that the role did not already allow. The driver excerpts, expected outcomes and decision logic show operators how to answer the question “What did the AssumeRole session actually get permission to do?” using complete evidence.
