Skip to content
Indra on Cloud
Go back

One Shared Service Account Would Have Been 200 Lines Shorter

One shared service account puts all 131 services in the blast radius. One per service puts one.

The largest tenant on the platform I work on runs 131 Cloud Run services, 77 Pub/Sub topics, 108 subscriptions and 60 schemas, all generated from a visual canvas and applied into the customer’s own cloud project.

It also carries 216 IAM bindings. Every one of those is a line of generated config that would not exist if all 131 services simply shared one service account, and for a while I was not certain the extra 200-odd lines were earning their keep.

They are. Here is the argument, including the parts that are genuinely annoying.

The shortcut is very tempting

One service account, granted the union of every permission any service needs, attached to all 131 services. It works immediately. The config is dramatically smaller. Nobody has to reason about which service talks to which.

What you have actually built is a system where the compromise of any single service is the compromise of all of them. The smallest, least-reviewed, most recently added service on the canvas now holds credentials that can publish to every topic, read every secret, and invoke every other service in the project.

You have also made the graph unreadable. When identity is shared, the permission set tells you nothing about the topology. You cannot look at IAM and learn what talks to what, which means you cannot answer “what breaks if I delete this” without reading all of the code.

Per-service identity inverts that. The bindings are the topology. Least privilege is the useful side effect; the documentation is the part I ended up valuing more.

Where 216 comes from

Nobody writes 216 of anything by hand. That number is not a decision, it is an emission: one binding per relationship, and a graph of 131 services has a lot of relationships.

Bindings arise per relationship: invoke, publish, subscribe, and secret access.

Four patterns account for almost all of it.

Invocation. A service that calls another needs run.invoker on that specific service, not on the project. Grant it at project level once and you have quietly recreated the shared-account problem with extra steps.

Publishing. A producer needs pubsub.publisher on the topics it actually writes to. This is the one where the temptation to go project-wide is strongest, because a service often publishes to several topics and the per-topic bindings feel repetitive.

Subscription delivery. This is the one that catches people. A Pub/Sub push subscription is not a passive pipe; it is a caller. It authenticates as its own service account and needs run.invoker on the target service, granted separately from whatever the publishing service has. Miss it and delivery fails with a 403 that appears in the subscription’s metrics rather than in your service logs, which is not where anybody looks first.

Secrets. secretAccessor per secret, per consumer. Project-level access to Secret Manager means every service can read every credential, which defeats the purpose of having put them there.

Notice that all four scale with relationships, not with services. Doubling the number of services roughly quadruples the number of ways they can talk to each other, which is why the binding count grows faster than the resource count and why it looks alarming before you work out where it came from.

Generation is what makes this affordable

Here is the honest reason this is achievable on a generated platform and rarely achievable on a hand-maintained one.

A human writing IAM gets bored. Around the twentieth binding, the shortcut starts to look reasonable, and it keeps looking reasonable for the remaining two hundred. That is not a discipline failure, it is an entirely predictable response to repetitive work with no immediate feedback.

A generator does not get bored. If the rule is “emit an invoker binding for each edge in the call graph”, it emits the two hundredth as carefully as the first. The canvas already knows the topology, because the customer drew it. The bindings fall out of the graph rather than being maintained alongside it, which also means they cannot drift away from it.

So the tradeoff is not “least privilege versus convenience”. It is “least privilege costs almost nothing if the topology is already machine-readable, and is nearly impossible if it is not”.

What it costs you anyway

Two things, and neither is theoretical.

IAM is eventually consistent. Bindings take time to propagate. On a fresh apply that creates a service and its bindings together, the first requests can fail with permission errors while the grants settle. This looks exactly like a misconfiguration and is not one. Anything that verifies immediately after an apply needs to expect it.

The plan gets noisy. 216 bindings means a plan output where the interesting change sits among a large number of uninteresting ones. This is a real reviewability cost, and it is part of why the tail latency of these applies mattered enough to measure. Splitting state by lifecycle helped: routine deploys stop re-evaluating the tenant’s foundations, so the bindings that never change also stop appearing in every plan.

What I would tell someone starting this

Make the identity boundary match the deployment boundary. One service, one identity. Anything coarser and you have to reason about the union of permissions, which nobody does correctly for long.

Grant on the resource, never on the project. Project-level roles are how per-service identity quietly becomes shared identity while the config still looks careful.

Remember that push subscriptions are callers. They need their own invoker grant. This is the single most common missing binding I have run into, and its error surfaces somewhere you are not looking.

Do not fight the binding count. It scales with relationships and it is supposed to. If the number looks too large, the thing to check is whether your services are too chatty, not whether your IAM is too careful.


Share this post:

Previous Post
Every Deploy Reported Success. The Endpoint Had Been Unreachable for Two Weeks.
Next Post
Page 1 Was Fast. Page 400 Timed Out.