Postgres on Google Cloud / Lesson 4 of 8

Three layers that must all be true

This is the mental model the rest of the course runs on. If you keep one page, keep this one.

≈ 15 minutes · the diagnostic skill · read it twice

"I can't connect to the database" is three completely different problems wearing the same sentence. They live in different systems, are fixed by different people, and fail at different moments.

Once you can name which layer an error belongs to, this stops being mysterious and becomes a lookup.

1
Project IAM — may this identity reach the instance at all?
Two roles on the project: roles/cloudsql.client (open a connection) and roles/cloudsql.instanceUser (log in with IAM authentication). Managed by whoever administers the cloud project, usually in Terraform. Fails when the proxy starts.
2
The IAM database user — does this identity exist on the instance?
A record on the instance mapping a Google principal to a PostgreSQL username. Added per user, per service account, or — best — per group. Fails at login, after the proxy is already working.
3
PostgreSQL grants — what may this user do once inside?
Ordinary GRANT statements. Nothing to do with Google. Run by someone with SQL access. Fails on your first query.

Reading the failure

The gift of this design is that each layer fails at a different moment as well as with a different message. The moment alone narrows it to one:

When it breaksMessageLayer
Proxy will not start 403 … connectSettings 1 — no cloudsql.client
Proxy runs, psql refuses password authentication failed for user "…" 2 — not an IAM database user
Connected, first query permission denied for table … 3 — no grants
Connected, CREATE TABLE permission denied for schema public 3 — and usually intentional, see lesson 7
Say this out loud once

Layer 1 fails before the proxy works. Layer 2 fails after the proxy works. Layer 3 fails after you are connected. That sentence resolves most tickets before anyone opens a console.

Layer 1 — the two roles, and why both

RoleKey permissionWithout it
roles/cloudsql.client cloudsql.instances.connect The proxy cannot fetch instance metadata or an ephemeral certificate. It dies on startup.
roles/cloudsql.instanceUser cloudsql.instances.login The tunnel opens; IAM login is refused.

Google's documentation describes the second as a predefined role that contains the necessary Cloud SQL IAM cloudsql.instances.login permission. You need this permission to login to a database instance with IAM database authentication (Cloud SQL docs). Grant both, together, to a group:

gcloud projects add-iam-policy-binding PROJECT_ID \
  --member="group:db-users@example.com" \
  --role="roles/cloudsql.client"

gcloud projects add-iam-policy-binding PROJECT_ID \
  --member="group:db-users@example.com" \
  --role="roles/cloudsql.instanceUser"
These bindings are project-wide

Cloud SQL has no per-instance IAM policy. A binding on the project applies to every instance in it. The usual way to make a production boundary real is therefore to put production in its own project. If production shares a project with development, granting a developer layer 1 on development grants it on production too — and only layers 2 and 3 are then standing between them and production data.

New bindings take about a minute

IAM changes propagate. A call that returns 403 immediately after a binding is created may succeed 40 seconds later. Wait before changing anything. Re-granting a binding that was already correct just spends the time it would have taken to propagate.

Layer 2 — becoming a database user

Holding the roles does not make you a database user. That is a separate record on the instance, and there are three kinds:

TypeUsername in PostgreSQLNotes
User you@example.com The full email address.
Service account app@PROJECT_ID.iam The .gserviceaccount.com suffix is dropped. Google: for a service account, this is the service account's email without the .gserviceaccount.com domain suffix.
Group db-users@example.com Every member inherits login and the group's privileges. Members are not added individually.
# a person
gcloud sql users create you@example.com \
  --instance=INSTANCE_NAME --type=cloud_iam_user

# a service account — full email here, suffix and all
gcloud sql users create app@PROJECT_ID.iam.gserviceaccount.com \
  --instance=INSTANCE_NAME --type=cloud_iam_service_account

# a group — the one to reach for
gcloud sql users create db-users@example.com \
  --instance=INSTANCE_NAME --type=cloud_iam_group
Two spellings of the same service account

When you create the user, you pass the full service account email including .gserviceaccount.com. When you connect, you use the form without it. Getting this backwards produces password authentication failed, which points at layer 2 and tells you nothing about why.

Groups are the answer to offboarding

With group authentication, Google's documentation notes that a member gains the ability to log in to the Cloud SQL instance because the user or service account belongs to the group and automatically inherits any database privileges that have been granted to the group (IAM authentication). Removing them from the group removes both. No Terraform run, no SQL, no instance change.

One constraint worth knowing before you design around it: you cannot add an individual user to an instance and add a group they belong to. Pick one model per instance.

Layer 3 — grants, which are not Google's problem

A fresh IAM database user has nothing. From the docs: when an IAM user is added to a database, by default the new database user has no privileges to any database. So the third layer is plain SQL, run by someone who already has it:

-- as a role that can grant
GRANT app_rw TO "you@example.com";

That grants membership of a group role which carries the actual privileges, rather than granting tables directly. Why that is the right shape — and how it goes wrong on an instance holding more than one database — is lesson 7.

Grants go stale, silently

A GRANT … ON ALL TABLES IN SCHEMA applies to the tables that exist at that moment. Every table created afterwards — by a migration, by a restore — is not covered. Nothing reports this. You find out when someone reads a new table and cannot. The durable fix is ALTER DEFAULT PRIVILEGES; the interim one is re-running your grants script after every migration and every restore.

The diagnosis, as a routine

Check yourself

The proxy starts fine. psql says password authentication failed. Which layer?

The proxy starting proves layer 1. The failure is at login, which is layer 2. For a service account, suspect the .gserviceaccount.com suffix before anything else.

A migration added tables. Users who could read everything yesterday get permission denied on the new ones. Why?

GRANT … ON ALL TABLES is a snapshot, not a standing rule. New tables are not covered. Re-run the grants, and set ALTER DEFAULT PRIVILEGES so it stops happening.

Why does granting a developer database access on a shared project weaken a production boundary?

There is no per-instance IAM policy. A project-level binding covers every instance in the project, which is why production is usually given a project of its own. Layers 2 and 3 still stand in the way — but layer 1 has stopped contributing.

From memory: the three layers, and the moment each one fails.

1. Project IAMcloudsql.client plus cloudsql.instanceUser — fails before the proxy works. 2. IAM database user — fails at login, after the proxy works. 3. PostgreSQL grants — fails on the first query.