Chapter 27 — Agent Identity and Authorization
Outreach on O-1001 proposes calling get_payment_method to “personalize” Jordan’s shipment update. Persuasive model text is not a scope grant — payment instrument access belongs to no ShopOps agent by default.
principal + scopes + tenant_id → middleware checks → tool executes or denies
Without per-agent identities, every worker shares one god credential. A compromised Outreach process reads what Intake should never touch.
This chapter adds principals and least privilege: agent:intake, agent:resolution, agent:policy, agent:outreach each get only the tools and fields their stage needs; humans mint short-lived send capabilities after approval.
First principles
- Authorization is independent of the model. Scopes live on identities issued by the control plane, not in prompts or rationales.
- Every agent is a principal.
agent:intake,agent:outreach,human:reviewer-17,service:orchestrator— different rights. - Least privilege is per tool. Read order PII ≠ read payment instrument ≠ send email.
- Delegation is explicit. Humans or orchestrators may mint short-lived capabilities (e.g.
outreach:sendafter approval). Models may not. - Stolen tool output ≠ stolen credentials. A JSON blob that says
"scopes": ["outreach:send"]must be rejected on sight. tenant_idis part of identity. A cross-store read is an authorization failure (Ch 33).
Concrete example (ShopOps)
| Principal | May call | Must not |
|---|---|---|
agent:intake | get_customer, get_order_pii, write_intake_fact | send_email, approve_action |
agent:resolution | read order facts, propose_resolution | payment-method PII, send |
agent:policy | judge_policy | send, write memory |
agent:outreach | draft_email | send without minted scope + ticket |
human:reviewer | approve_action (with evidence pack) | raw payment-method write APIs |
For O-1001, Outreach can draft a message but lacks outreach:send; the middleware denies a send proposal. After human approval, the orchestrator attaches a one-shot capability and the audit trail records the actor and approval. The model never mints that capability.
Diagram — identity planes
┌──────────── identity plane ────────────┐
│ issuers: IAM / SPIFFE / internal CA │
│ principals + scopes + tenant_id │
└─────────────────┬──────────────────────┘
│ signed / server-side session
▼
┌──────────── agent runtime ─────────────┐
│ Authz.require(principal, tool) │
│ │ │
│ ▼ │
│ policy engine → executor → systems │
└────────────────────────────────────────┘
┌──────────── model plane ───────────────┐
│ proposes actions / args / rationale │
│ cannot mint scopes or credentials │
└────────────────────────────────────────┘
Caption: Notice two planes — identity issues rights; the model only proposes within them.
sequenceDiagram
participant M as Model
participant A as Authz
participant E as Executor
participant T as Tool
M->>A: propose get_order_pii
A-->>M: deny (outreach lacks scope)
M->>A: propose draft_email
A->>E: allow
E->>T: draft_email
Implementation
Reference module: shopops/authz.py.
from shopops.authz import (
Authz,
assert_no_scope_from_untrusted,
Principal,
PrincipalKind,
)
authz = Authz()
outreach = authz.principal_for_agent("outreach", tenant_id="store_northline")
# outreach.scopes == frozenset({"outreach:draft"})
authz.require(outreach, "draft_email") # ok
try:
authz.require(outreach, "get_order_pii")
except Exception as e:
print(e) # agent:outreach lacks scope order_pii:read
# After human approval, orchestrator mints send — not the model:
sender = authz.with_send_capability(outreach)
authz.require(sender, "send_email")
# Property: tool payloads cannot grant scopes
assert_no_scope_from_untrusted({"ok": True, "scopes": ["outreach:send"]})
Signed actions (production shape): the approval service returns a capability token bound to case_id, action, exp, and approval_id. The executor verifies signature and binding before upgrading scopes. Teaching code skips crypto and uses an in-process with_send_capability to keep the lesson clear: minting is a privileged API, not a prompt outcome.
Failure modes
| Failure | Symptom | Mitigation |
|---|---|---|
| One API key for all tools | Any agent can send | Per-agent credentials + scopes |
| Scope in prompt | “You may send” in system text | Scopes only on Principal |
| Confused deputy | Intake agent called with Outreach’s token | Bind token to principal id |
| Over-broad human roles | Reviewers get prod DB admin | Separate approve vs operate |
| Token leakage into traces | Scopes/secrets in logs | Redact; never log credentials |
| Forged approval_id | Model invents ticket UUID | Ticket store lookup, not string trust |
Production considerations
- Prefer workload identity (e.g. SPIFFE) for agent services; rotate keys.
- Short TTL on
outreach:sendcapabilities; one use where possible. - Propagate
principal_idandtenant_idon every trace span. - Negative tests: Outreach cannot read payment instrument; Intake cannot send.
- Separate authentication (who) from authorization (what) from policy (business rules). All three show up in audits.
Chapter summary
- Authorization does not live in the language model.
- Agents, humans, and services are distinct principals with scopes.
- Least privilege is per tool and per tenant.
- Only privileged control-plane APIs mint send capabilities.
- Untrusted payloads must not carry scopes or credentials.
- Forged approval identifiers are data, not authority.
- Trace the principal on every consequential action.
- Authz failures are expected control flow, not “model errors.”
Exercises
- Mechanical. Using
Authz, proveagent:outreachcandraft_emailand cannotget_order_pii. - Property. Feed
assert_no_scope_from_untrusteda matrix of hostile dicts; all must raise. - Design. Specify a signed capability token schema for “approve send email on case O-1001, expires 15m, single use.”
References
- OAuth 2.0 / OIDC concepts — scopes, audience, delegation. [VERIFY]
- SPIFFE / SPIRE — workload identity (optional production mapping). [VERIFY]
- OWASP LLM Top 10 — excessive agency / over-privileged tools. [VERIFY]
- Chapters 5, 26, 28, 33