You run a deployment pipeline with three stages: an orchestrator reads the deployment config, an analyst reviews the target environment, and a deployer pushes the actual code. Each stage needs less authority than the one before it. The orchestrator has broad access to configs and deploy targets. It delegates a narrow slice to the analyst, who delegates an even narrower slice to the deployer.
This creates a three-hop delegation chain: Orchestrator → Analyst → Deployer. Each hop narrows the scope. The deployer can only push to one specific service in one specific environment — it cannot read configs, it cannot deploy other services, and it cannot touch staging.
This app demonstrates the SDK's multi-hop delegation limitation: agent.delegate() always uses the agent's registration token, not a received delegated token. For the second hop, you must use raw HTTP with the delegated token as the Bearer credential.
| Concept | Why It Matters |
|---|---|
| Multi-hop delegation (A→B→C) | Attenuating scope across three agents — each hop in this example narrows further |
| Raw HTTP for second delegation hop | The SDK's delegate() uses the registration token; multi-hop needs the delegated token |
| Delegation chain depth | The chain records every hop — depth is limited to 5 |
| Validating at each hop | Confirming the broker issued the scope you requested at each step |
AuthorizationError on scope violation |
What happens when a delegation tries to escalate scope |
┌──────────────────────────────────────────────────────────────────┐
│ Deployment Runner Script │
│ │
│ Orchestrator scope: │
│ read:config:*, read:deploy:*, write:deploy:* │
│ │
│ Hop 1 (SDK): Orchestrator → Analyst │
│ Delegated: read:config:production, read:deploy:web-service │
│ Dropped: write:deploy:* (analyst is read-only) │
│ │
│ Hop 2 (Raw HTTP): Analyst → Deployer │
│ Delegated: write:deploy:web-service │
│ Dropped: read:config:* (deployer doesn't need config) │
│ │
│ Result: │
│ Orchestrator — full access │
│ Analyst — can read config and deploy status for one service │
│ Deployer — can ONLY push web-service to production │
└──────────────────────────────────────────────────────────────────┘
# deploy_runner.py
# Run: python deploy_runner.py
from __future__ import annotations
import os
import sys
import httpx
from agentwrit import (
AgentWritApp,
DelegatedToken,
scope_is_subset,
validate,
)
from agentwrit.errors import AuthorizationError
def main() -> None:
app = AgentWritApp(
broker_url=os.environ["AGENTWRIT_BROKER_URL"],
client_id=os.environ["AGENTWRIT_CLIENT_ID"],
client_secret=os.environ["AGENTWRIT_CLIENT_SECRET"],
)
broker_url = app.broker_url
print("CI/CD Deployment Runner — Multi-Hop Delegation")
print("=" * 55)
print()
# ── Create all three agents ─────────────────────────────────
orchestrator = app.create_agent(
orch_id="deploy-pipeline",
task_id="release-v2.4.1",
requested_scope=[
"read:config:*",
"read:deploy:*",
"write:deploy:*",
],
)
print(f"Orchestrator created")
print(f" ID: {orchestrator.agent_id}")
print(f" Scope: {orchestrator.scope}")
print()
analyst = app.create_agent(
orch_id="deploy-pipeline",
task_id="review-v2.4.1",
requested_scope=[
"read:config:*",
"read:deploy:*",
],
)
print(f"Analyst created")
print(f" ID: {analyst.agent_id}")
print(f" Scope: {analyst.scope}")
print()
deployer = app.create_agent(
orch_id="deploy-pipeline",
task_id="push-v2.4.1",
requested_scope=[
"write:deploy:*",
],
)
print(f"Deployer created")
print(f" ID: {deployer.agent_id}")
print(f" Scope: {deployer.scope}")
print()
# ── Hop 1: Orchestrator → Analyst (SDK) ─────────────────────
# Orchestrator delegates a narrow slice: only production config
# and only the web-service deploy target.
hop1_scope = [
"read:config:production",
"read:deploy:web-service",
]
print(f"Hop 1: Orchestrator → Analyst")
print(f" Delegating: {hop1_scope}")
delegated_ab: DelegatedToken = orchestrator.delegate(
delegate_to=analyst.agent_id,
scope=hop1_scope,
ttl=120,
)
print(f" Success! Chain depth: {len(delegated_ab.delegation_chain)}")
print(f" Delegated token: {delegated_ab.access_token[:30]}...")
print()
# Validate hop 1
hop1_result = validate(broker_url, delegated_ab.access_token)
if hop1_result.valid and hop1_result.claims is not None:
print(f" Hop 1 validated scope: {hop1_result.claims.scope}")
if hop1_result.claims.delegation_chain:
print(f" Chain entries: {len(hop1_result.claims.delegation_chain)}")
print()
# ── Hop 2: Analyst → Deployer (Raw HTTP) ────────────────────
# The SDK's analyst.delegate() would use the analyst's REGISTRATION
# token, not the delegated token from hop 1. For a true multi-hop
# chain, we must use the delegated token as the Bearer credential.
hop2_scope = [
"write:deploy:web-service",
]
print(f"Hop 2: Analyst → Deployer (raw HTTP)")
print(f" Delegating: {hop2_scope}")
print(f" Using delegated token from hop 1 as Bearer")
resp = httpx.post(
f"{broker_url}/v1/delegate",
json={
"delegate_to": deployer.agent_id,
"scope": hop2_scope,
"ttl": 60,
},
headers={"Authorization": f"Bearer {delegated_ab.access_token}"},
timeout=10,
)
if resp.status_code != 200:
print(f" FAILED: {resp.status_code} — {resp.text}")
sys.exit(1)
hop2_data = resp.json()
print(f" Success! Token: {hop2_data['access_token'][:30]}...")
hop2_chain = hop2_data.get("delegation_chain", [])
print(f" Chain depth: {len(hop2_chain)}")
for i, entry in enumerate(hop2_chain):
print(f" [{i}] {entry['agent']} → scope: {entry['scope']}")
print()
# Validate hop 2
hop2_result = validate(broker_url, hop2_data["access_token"])
if hop2_result.valid and hop2_result.claims is not None:
print(f" Hop 2 validated scope: {hop2_result.claims.scope}")
if hop2_result.claims.delegation_chain:
print(f" Chain entries: {len(hop2_result.claims.delegation_chain)}")
print()
# ── Scope Isolation Checks ──────────────────────────────────
print("── Scope Isolation ──")
print()
# Orchestrator can read all configs
if scope_is_subset(["read:config:staging"], orchestrator.scope):
print(f" Orchestrator CAN read staging config ✓")
if scope_is_subset(["write:deploy:payment-svc"], orchestrator.scope):
print(f" Orchestrator CAN deploy payment-svc ✓")
# Delegated analyst scope is narrow
analyst_scope = hop1_scope
if not scope_is_subset(["read:config:staging"], analyst_scope):
print(f" Analyst CANNOT read staging config (only production) ✓")
if not scope_is_subset(["write:deploy:web-service"], analyst_scope):
print(f" Analyst CANNOT write deploy (read-only) ✓")
if scope_is_subset(["read:config:production"], analyst_scope):
print(f" Analyst CAN read production config ✓")
# Delegated deployer scope is narrowest
deployer_delegated = hop2_scope
if not scope_is_subset(["read:config:production"], deployer_delegated):
print(f" Deployer CANNOT read configs ✓")
if not scope_is_subset(["write:deploy:payment-svc"], deployer_delegated):
print(f" Deployer CANNOT deploy payment-svc ✓")
if scope_is_subset(["write:deploy:web-service"], deployer_delegated):
print(f" Deployer CAN deploy web-service ✓")
print()
# ── Cleanup ─────────────────────────────────────────────────
orchestrator.release()
analyst.release()
deployer.release()
print("All agents released.")
if __name__ == "__main__":
main()This app uses the universal sample app registered in the README setup. If you've already registered it, skip to Running It.
| Ceiling Scope | What This App Requests | Why |
|---|---|---|
read:config:* |
Orchestrator reads config, analyst reads production config | Config review |
read:deploy:* |
Orchestrator and analyst read deploy status | Pre-deploy checks |
write:deploy:* |
Orchestrator deploys anything, deployer deploys one service | Push code |
Why
read:config:*and notread:config:production? The app ceiling is broad — the orchestrator might deploy to staging, production, or any environment. The narrowing happens at the agent level and through delegation. The orchestrator delegatesread:config:production(not*) to the analyst.
This app uses httpx for the raw HTTP delegation hop. Install it:
uv add httpxexport AGENTWRIT_BROKER_URL="http://127.0.0.1:8080"
export AGENTWRIT_CLIENT_ID="<from registration>"
export AGENTWRIT_CLIENT_SECRET="<from registration>"
uv run python deploy_runner.pyCI/CD Deployment Runner — Multi-Hop Delegation
=======================================================
Orchestrator created
ID: spiffe://agentwrit.local/agent/deploy-pipeline/release-v2.4.1/a1b2...
Scope: ['read:config:*', 'read:deploy:*', 'write:deploy:*']
Analyst created
ID: spiffe://agentwrit.local/agent/deploy-pipeline/review-v2.4.1/c3d4...
Scope: ['read:config:*', 'read:deploy:*']
Deployer created
ID: spiffe://agentwrit.local/agent/deploy-pipeline/push-v2.4.1/e5f6...
Scope: ['write:deploy:*']
Hop 1: Orchestrator → Analyst
Delegating: ['read:config:production', 'read:deploy:web-service']
Success! Chain depth: 1
Delegated token: eyJhbGciOiJFZERTQSIsInR5cCI6...
Hop 1 validated scope: ['read:config:production', 'read:deploy:web-service']
Chain entries: 1
Hop 2: Analyst → Deployer (raw HTTP)
Delegating: ['write:deploy:web-service']
Using delegated token from hop 1 as Bearer
Success! Token: eyJhbGciOiJFZERTQSIsInR5cCI6...
Chain depth: 2
[0] spiffe://.../release-v2.4.1/a1b2... → scope: ['read:config:*', ...]
[1] spiffe://.../review-v2.4.1/c3d4... → scope: ['read:config:production', ...]
Hop 2 validated scope: ['write:deploy:web-service']
Chain entries: 2
── Scope Isolation ──
Orchestrator CAN read staging config ✓
Orchestrator CAN deploy payment-svc ✓
Analyst CANNOT read staging config (only production) ✓
Analyst CANNOT write deploy (read-only) ✓
Analyst CAN read production config ✓
Deployer CANNOT read configs ✓
Deployer CANNOT deploy payment-svc ✓
Deployer CAN deploy web-service ✓
All agents released.
-
The SDK's
delegate()only works for single-hop delegation. It always uses the agent's registration token. For multi-hop chains (A→B→C), the second hop must use the delegated token directly as a Bearer credential via raw HTTP. -
The chain records every hop. After two hops, the
delegation_chainhas two entries — one for each delegation. Each entry records the delegator's SPIFFE ID, their scope at the time, and a timestamp. This creates a complete audit trail of who authorized what. -
Maximum depth is 5 hops. The broker enforces a depth limit. A→B→C→D→E→F is the deepest chain allowed. If you try a 6th hop, the broker returns 403.
-
No hop can widen scope. The orchestrator has
read:config:*and delegatesread:config:production(narrower) — a design choice this example makes. The analyst cannot re-delegateread:config:staging: it doesn't hold that scope, so the broker rejects the delegation. Equal-scope delegation is accepted (narrowing is a pattern, not a rule); any widening is rejected. -
All three agents must be registered first. Delegation targets a SPIFFE ID that already exists in the broker. You can't delegate to an agent you haven't created yet.