
ADK Agent Identity on Google Cloud: Separate Identities, Separate Blast Radius
When a model-driven orchestrator delegates a task to a specialist agent, something specific happens at the infrastructure layer: one Cloud Run service makes an authenticated HTTP call to another. The caller presents its own service account token. The callee checks IAM before accepting anything.
What doesn’t happen matters just as much. The orchestrator’s identity isn’t inherited by the sub-agent. The sub-agent runs as itself (its own service account, its own IAM scope), and whatever the orchestrator is permitted to do has no bearing on what the sub-agent is permitted to do. That’s the architecture. Most multi-agent implementations don’t make it explicit, and a fair number quietly assume the opposite.
In the Antigravity article that separation was a conceptual argument about governance. Here it’s two Cloud Run services, two service accounts, and a specific IAM binding you can point at. And, as it turned out, a wrong default I had to catch and fix along the way.
Figure 1: Two Cloud Run services, two service accounts. The run.invoker binding is scoped to one named service, not the project, and that’s what defines the blast radius in either direction.
Building the System in Antigravity
Two agents, both deliberately simple. The orchestrator receives a query and decides whether to answer it directly or delegate to a specialist. The specialist retrieves a document summary. Neither does anything more sophisticated than that, because the subject of this article is the identity layer sitting underneath them, not the reasoning inside them.
Antigravity scaffolds and deploys the sub-agent first, through agents-cli:
Use agents-cli to build a simple document retrieval specialist agent
using ADK. The agent accepts a query and returns a document summary.
Deploy to Cloud Run in europe-west2 with authentication required.
Name the service: doc-retrieval-agent
agents-cli create doc-retrieval-agent --prototype --yes
cd doc-retrieval-agent && agents-cli install
agents-cli scaffold enhance --deployment-target cloud_run --yes
agents-cli deploy
Figure 2: gemini-2.0-flash wasn’t available in europe-west2, so agents-cli fell back to gemini-2.5-flash on its own, a regional constraint I’d otherwise have had to catch by hand. Both agents run 2.5-flash from here on.
What agents-cli Gives You By Default
Both services deploy cleanly, both requiring authentication:
Figure 3: Two services, both authenticated at the ingress level. Nothing wrong with this picture yet.
But look at what identity they’re actually running as:
gcloud run services describe doc-retrieval-agent \
--region=europe-west2 --project=YOUR_PROJECT_ID \
--format="value(spec.template.spec.serviceAccountName)"
gcloud run services describe orchestrator-agent \
--region=europe-west2 --project=YOUR_PROJECT_ID \
--format="value(spec.template.spec.serviceAccountName)"
Figure 4: Same answer, twice. Both services are running as the default Compute Engine service account.
That’s the bit that made me stop and go back through the deploy. The default Compute Engine service account carries roles/editor at the project level, so two agents sharing it don’t just share access: a compromised sub-agent has Editor rights across the entire project, identical to the orchestrator’s. There’s no blast radius here at all, because nothing separates the two agents to begin with.agents-cli gets you to a working deployment fast. It doesn’t get you to a secure one automatically. The IAM design is left to you, and if you don’t go looking for it, this is what ships.
The Fix: Separate Identities
Dedicated service accounts, one per agent, each holding only what that agent needs:
gcloud iam service-accounts create doc-retrieval-sa \
--project=YOUR_PROJECT_ID --display-name="Doc Retrieval Sub-Agent"
gcloud iam service-accounts create orchestrator-sa \
--project=YOUR_PROJECT_ID --display-name="Orchestrator Agent"
# Sub-agent: Vertex AI only
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:doc-retrieval-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/aiplatform.user"
# Orchestrator: Vertex AI, plus invoker on the sub-agent service only
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:orchestrator-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/aiplatform.user"
gcloud run services add-iam-policy-binding doc-retrieval-agent \
--region=europe-west2 --project=YOUR_PROJECT_ID \
--member="serviceAccount:orchestrator-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/run.invoker"
# Point each Cloud Run service at its dedicated SA
gcloud run services update doc-retrieval-agent \
--region=europe-west2 --project=YOUR_PROJECT_ID \
--service-account=doc-retrieval-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com
gcloud run services update orchestrator-agent \
--region=europe-west2 --project=YOUR_PROJECT_ID \
--service-account=orchestrator-sa@YOUR_PROJECT_ID.iam.gserviceaccount.com
echo "doc-retrieval-agent SA:" && gcloud run services describe doc-retrieval-agent \
--region=europe-west2 --project=YOUR_PROJECT_ID --format="value(spec.template.spec.serviceAccountName)"
echo "orchestrator-agent SA:" && gcloud run services describe orchestrator-agent \
--region=europe-west2 --project=YOUR_PROJECT_ID --format="value(spec.template.spec.serviceAccountName)"
Figure 5: Different answers now. That’s the whole fix, and it’s the difference between Figure 4 and this one that the rest of the article’s argument rests on.
The scoping decision inside that fix is the one that actually matters, at least to me: roles/run.invoker is granted on the sub-agent’s specific service, not at the project level. Project-level would let the orchestrator invoke any Cloud Run service in YOUR_PROJECT_ID; service-level lets it invoke exactly one. The intent was always the second (which, to be clear, was the only intent I had), and getting there costs one extra flag.
Antigravity’s own deploy flow confirms the same binding, from its own summary:
Figure 6: Antigravity gets the run.invoker binding right for the delegation, while still defaulting the service account underneath it to the project-wide Compute Engine one. It gets the binding right and still leaves the identity choice to you.
Testing the Delegation Chain
The ADK API on Cloud Run doesn’t auto-create sessions. Each caller creates one explicitly before it can send a message:
export TOKEN=$(gcloud auth print-identity-token)
curl -s -X POST "$ORCH_URL/apps/app/users/user_001/sessions/session_010" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
curl -s -X POST "$ORCH_URL/run_sse" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"app_name":"app","user_id":"user_001","session_id":"session_010","new_message":{"role":"user","parts":[{"text":"Retrieve a document summary on IAM best practices for Cloud Run"}]},"streaming":false}'
Figure 7: The delegation shows up in the response itself: transfer_to_agent fires with agent_name: doc_retrieval_agent, and the synthesised summary comes back through the orchestrator.
Worth flagging: app_name in these calls is "app", not orchestrator_agent, which is what agents-cli names it by default on Cloud Run, regardless of what the agent itself is called.
A general question, run the same way, shows the orchestrator isn’t delegating everything by reflex:
curl -s -X POST "$ORCH_URL/run_sse" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"app_name":"app","user_id":"user_001","session_id":"session_008","new_message":{"role":"user","parts":[{"text":"What is the capital of France?"}]},"streaming":false}'
Figure 8: No delegation, no A2A call, nothing sent to the sub-agent. The routing decision is real, not a formality.
Observability: What the Trace Shows
Cloud Trace picks up the A2A boundary crossing when the orchestrator initialises its connection to the sub-agent:
Figure 9: The agent-card discovery call RemoteA2aAgent makes on initialisation: GET /a2a/app/.well-known/agent-card.json, 200, 9.561ms.
I want to be honest about what this does and doesn’t show, rather than let the screenshot imply more than it does. It confirms the delegation happened and that the orchestrator authenticated successfully to the sub-agent, which is the useful part. It doesn’t show the sub-agent’s own LLM call as a separate span here: Cloud Trace is capturing the HTTP boundary crossing, not the work happening once the request lands. The path itself is also worth a note: /a2a/app/.well-known/agent-card.json, not the /.well-known/agent.json the A2A spec describes elsewhere. Took me a minute to find it the first time. agents-cli’s Cloud Run scaffold nests it under the app route.
The Blast Radius Proof
Everything above is a claim about what the IAM bindings allow. Two things prove it.
First, an unauthenticated call against the orchestrator:
curl -s -X POST "$ORCH_URL/run_sse" \
-H "Content-Type: application/json" \
-d '{"app_name":"app","user_id":"u","session_id":"s","new_message":{"role":"user","parts":[{"text":"test"}]}}' \
-w "\nHTTP Status: %{http_code}\n"
Figure 10: 403. The authentication requirement is being enforced, not just configured and hoped for.
Second, and this is the one that actually answers the question the whole article is asking, who can invoke the orchestrator:
gcloud run services get-iam-policy orchestrator-agent \
--region=europe-west2 --project=YOUR_PROJECT_ID \
--format="table(bindings.members, bindings.role)"
Figure 11: Nothing. No member holds run.invoker on the orchestrator, not the sub-agent’s service account, not anything else.
That empty table is the blast radius proof. The sub-agent has no IAM path back to the orchestrator at all. Even if it were compromised and tried to escalate by calling the orchestrator directly, there’s no binding that would let the request through. The boundary is sitting in IAM, not in how well-behaved the agent’s code happens to be.
Limitations
RemoteA2aAgent is experimental. ADK logs a UserWarning on startup that its A2A support (RemoteA2aAgent, A2aAgentExecutor, and the surrounding pieces) is experimental and subject to breaking changes. Not a reason to avoid it, in my view. Just a reason to pin your ADK version and re-test after every upgrade.
Agent card discovery needed public access, and this is the one that bugged me most. For the orchestrator to fetch /a2a/app/.well-known/agent-card.json at initialisation, that endpoint had to be reachable without an identity token, meaning allUsers gets run.invoker on the sub-agent, loosening exactly the control this article spends most of its length arguing for. In production, the better path is a private VPC with internal-only A2A traffic, or an authenticated agent-card fetch once ADK supports one cleanly.
Fixing the default service account once doesn’t make it disappear. agents-cli deploys onto the default Compute Engine service account, roles/editor and all, unless you explicitly point it elsewhere. Creating dedicated accounts for these two agents doesn’t strip that default account’s standing access. Anything else in the project still using it keeps the wide blast radius alive.
Identity isn’t the same as intent. Dedicated service accounts define what each agent can do. They don’t define what each agent should do. A sub-agent with perfectly scoped IAM can still be prompted into acting outside its intended purpose. That’s a policy problem sitting above IAM, and it’s what the Agent Registry and Agent Gateway are for, not this layer.
Sessions are explicit, not automatic. The ADK API on Cloud Run requires a session to be created via POST /apps/{app_name}/users/{user_id}/sessions/{session_id} before /run_sse will accept a message. Not a limitation so much as a deployment detail worth knowing before your first request mysteriously fails.
Closing
Two agents with separate identities isn’t a complex architecture, and I don’t think it needs to be sold as one. It’s just the simplest correct setup for a multi-agent system that takes governance seriously. And it’s not the default you get for free. agents-cli handed both agents the same project-wide identity until I went and separated them myself, and I’d rather flag that gap between “deployed” and “correctly scoped” than pretend I caught it on the first pass.
The orchestrator can call the sub-agent because IAM says so. The sub-agent can’t call the orchestrator because IAM says so: the empty policy table proves it, not my say-so. Neither can exceed its own scope, because IAM says so, not because the code is well-behaved and not because nothing has gone wrong yet.
That’s not complete governance. But it’s the right foundation, in the same spirit as the identity work in the BigQuery build: get the identity layer right first, and catch the defaults that don’t. Everything else gets built on top of it.