IAM for agents: the agent as a principal
Every chapter so far has treated security as a property of individual pieces: the sandbox wall, the guardrail, the tool schema. Part 8 makes it a plane. This first chapter is the foundation the rest stands on: the agent is a principal, an identity that acts, and the single most important security decision in the whole platform is what that identity is allowed to do. Get this right and injection, multi-tenancy, and blast radius all become tractable; get it wrong and no downstream control can save you, because you have handed the model, and anyone who steers it, real authority.
The agent acts as someone
Return to the concepts chapter's vocabulary: a
principal is anything that can be authenticated, a role is a
bundle of permissions a principal can assume, and least privilege
is granting only what the task at hand needs. An agent is a principal.
When its s3:GetObject tool call reaches storage, the storage service
asks the oldest question in computing, who is asking, and the answer
is an IAM identity with a policy attached. That policy, not the prompt,
not the guardrail, not the model's good intentions, is what the
storage service enforces.
This reframes the security problem productively. The prompt is advice the model may or may not follow; the IAM policy is a wall the model cannot argue with, evaluated by AWS on every call, outside the loop entirely (the control ladder's rung four, now with a name and a service). So the question "what can go wrong if this agent is fully compromised" has a precise, answerable form: what does its IAM policy permit? If you cannot answer that from the policy alone, gate 1 of the production bar fails, and you are trusting the model where you should be trusting IAM.
The tension: broad role, narrow task
Here is the problem that makes agent IAM its own subject rather than ordinary service IAM. The same agent serves many tasks: it audits Alice's account this minute and Bob's the next, files a ticket here, reads a report there. Its role must therefore be broad enough for the union of everything any task might do. But any single task needs a tiny slice of that, and running each task with the full broad role is the confused-deputy trap waiting to spring: a task acting for Alice, holding a role that can also read Bob, will eventually be talked into reading Bob by a hostile input.
The resolution is AWS's session policies, and the property that makes them work is that they can only narrow. When code assumes a role with a session policy attached (via STS, the token service), the effective permission is the intersection of the role's policy and the session policy: a request must be allowed by both. You cannot widen a role with a session policy, only shrink it, which is exactly the safe direction. So the pattern is: a broad role the agent can assume, and a task-scoped session policy computed per task that shrinks it to precisely what this task, acting for this user, needs.
Lab 8.1: scoping in action
The evaluation rules are small enough to model exactly (default deny;
an explicit Deny always wins; wildcards match; the session narrows
by intersection), so the lab is a faithful policy engine, not an
approximation. It takes a broad agent role and a read-only session
scoped to Alice, and runs seven requests through both:
python3 iam_scope.py
action resource role +session
bedrock:InvokeModel claude-opus-4-8 allow allow
s3:GetObject arn:customers/alice/profile allow allow
s3:GetObject arn:customers/bob/profile allow DENY
s3:GetObject arn:customers/alice/ssn DENY DENY
dynamodb:GetItem table/agent-state/run-42 allow allow
dynamodb:PutItem table/agent-state/run-42 allow DENY
ticket:Create sev-high allow DENY
the broad role allowed 6 of 7 requests; the scoped session allows 3.
scoping removed 3 capabilities from this task's blast radius, including:
- reading Bob's data (confused-deputy defense: this task acts for Alice, so it can only touch Alice)
- writing anything (read-only session) and filing tickets (not this task's job)
note s3:GetObject on alice/ssn is DENY under both: an explicit Deny in the role wins even where the session would allow.
Read the two columns as before-and-after. The role column is the agent's standing authority: broad, because it serves every task. The +session column is one task's actual reach: three of seven requests, the exact slice this read-only-for-Alice task needs. The three removed rows are the chapter's whole argument made concrete:
- Bob's data: denied. The role could read it; the session cannot, because this task acts for Alice. That single narrowing is the confused-deputy defense: even a fully compromised agent on this task cannot reach Bob, because the wall is IAM, not the model's restraint. The attacker can misdirect what the agent asks for; they cannot expand what the session is permitted to grant.
- Writes and tickets: denied. This task reads; the session omits every mutating action, so nothing the model does can change state or file a ticket. Capability the task does not need is capability an attacker cannot borrow.
- The SSN: denied under both. An explicit
Denyin the role holds even where a session might allow, which is why genuinely never-touch data (SSNs, secrets) belongs in a role-levelDeny, not left to each session to remember to exclude. Defense in depth: the broad grant is still bounded.
The rules to build on
Three principles generalize from the lab into Hive's design, and they are cheap to adopt from day one and brutal to retrofit:
- One session policy per task, computed from the task. The scheduler that dispatches a unit (Chapter 23) also mints its scoped credentials: this user, this resource set, this action set, expiring with the unit. Task-scoped and short-lived are the same discipline; STS session credentials are both by construction.
- Never-touch data lives in a role
Deny. Session narrowing is for per-task scoping; role-levelDenyis for absolutes the platform will never grant regardless of task. The lab's SSN line is the pattern. - Read is default; write is earned. Most agent work reads; scope sessions to read-only unless a task genuinely needs to mutate, and even then, gate the mutation (Chapter 38). The gap between "the agent read something wrong" and "the agent did something wrong" is the gap between an embarrassment and an incident.
Don't be confused: authentication vs authorization. These are two questions and agent security needs both answered. Authentication is who is this (the agent runs under a verifiable IAM identity, not a shared key floating in a prompt, which the Identity chapter already insisted on). Authorization is what may they do (this chapter's session policies). A system that authenticates well but authorizes broadly knows exactly who breached it, which is cold comfort. The blast radius lives entirely in authorization.
Full source
"""Lab 8.1: scoping an agent's authority. A tiny IAM policy engine.
Models the two rules that make agent permissions safe: least privilege
(grant only what a task needs) and session scoping (an assumed-role
session can only NARROW the role's permissions, never widen them; the
effective permission is the intersection). Evaluation follows AWS
semantics: default deny, an explicit Deny always wins, wildcards match.
The demo takes a broad agent role, scopes a single task's session down
to exactly what it needs, and shows the diff (the blast radius the
scoping removed), including the confused-deputy case: an agent acting
for Alice cannot touch Bob's data even though the role could.
Standard library only. Deterministic.
"""
from __future__ import annotations
import fnmatch
from dataclasses import dataclass
@dataclass
class Statement:
effect: str # "Allow" or "Deny"
actions: list[str]
resources: list[str]
def matches(patterns: list[str], value: str) -> bool:
return any(fnmatch.fnmatch(value, p) for p in patterns)
def evaluate(policy: list[Statement], action: str, resource: str) -> bool:
"""AWS-style: explicit Deny wins; otherwise allow iff some Allow matches."""
allowed = False
for s in policy:
if matches(s.actions, action) and matches(s.resources, resource):
if s.effect == "Deny":
return False
allowed = True
return allowed
def effective(role: list[Statement], session: list[Statement],
action: str, resource: str) -> bool:
"""A session policy can only narrow: the request must pass BOTH."""
return (evaluate(role, action, resource)
and evaluate(session, action, resource))
# The agent's role: broad, because the same agent serves every task.
ROLE = [
Statement("Allow", ["bedrock:InvokeModel"], ["*"]),
Statement("Allow", ["dynamodb:GetItem", "dynamodb:PutItem"],
["table/agent-state/*"]),
Statement("Allow", ["s3:GetObject"], ["arn:customers/*"]),
Statement("Allow", ["ticket:Create", "ticket:Read"], ["*"]),
Statement("Deny", ["s3:GetObject"], ["arn:customers/*/ssn"]), # never SSNs
]
# One task's session: acting for Alice, read-only on her data, no tickets.
SESSION_ALICE_READONLY = [
Statement("Allow", ["bedrock:InvokeModel"], ["*"]),
Statement("Allow", ["dynamodb:GetItem"], ["table/agent-state/*"]),
Statement("Allow", ["s3:GetObject"], ["arn:customers/alice/*"]),
]
REQUESTS = [
("bedrock:InvokeModel", "claude-opus-4-8"),
("s3:GetObject", "arn:customers/alice/profile"),
("s3:GetObject", "arn:customers/bob/profile"),
("s3:GetObject", "arn:customers/alice/ssn"),
("dynamodb:GetItem", "table/agent-state/run-42"),
("dynamodb:PutItem", "table/agent-state/run-42"),
("ticket:Create", "sev-high"),
]
if __name__ == "__main__":
print(f"{'action':<22}{'resource':<28}{'role':>6}{'+session':>10}")
removed = 0
for action, resource in REQUESTS:
r = evaluate(ROLE, action, resource)
e = effective(ROLE, SESSION_ALICE_READONLY, action, resource)
if r and not e:
removed += 1
print(f"{action:<22}{resource:<28}"
f"{'allow' if r else 'DENY':>6}{'allow' if e else 'DENY':>10}")
print(f"\nthe broad role allowed "
f"{sum(evaluate(ROLE, a, r) for a, r in REQUESTS)} of "
f"{len(REQUESTS)} requests; the scoped session allows "
f"{sum(effective(ROLE, SESSION_ALICE_READONLY, a, r) for a, r in REQUESTS)}.")
print(f"scoping removed {removed} capabilities from this task's blast "
f"radius, including:")
print(" - reading Bob's data (confused-deputy defense: this task acts "
"for Alice, so it can only touch Alice)")
print(" - writing anything (read-only session) and filing tickets "
"(not this task's job)")
print("note s3:GetObject on alice/ssn is DENY under both: an explicit "
"Deny in the role wins even where the session would allow.")
👉 Next: prompt injection in production, where the attacker stops needing a bug and simply asks the model to misbehave, and the scoped authority from this chapter becomes the wall that holds when the model says yes.