Postgres on Google Cloud / Lesson 7 of 8
Three PostgreSQL facts that quietly defeat a perfectly correct Google Cloud setup. All three were found by testing, not by reading.
You have layers 1 and 2. Nobody has a password, every connection is attributable, and offboarding is one group removal. Then someone runs a verification query and finds the development application can read production data.
Nothing in Google Cloud failed. Layer 3 is PostgreSQL, and PostgreSQL has three behaviours that surprise people. Each has cost a real team real time.
The intuitive design is two roles: app_rw for the application,
app_ro for read-only humans. Revoke CREATE on the
schema so the application cannot create tables, and you are done.
Here is what that misses. Consider how tables usually come to exist: a
migration runs, or a dump is restored, as some administrative role. Later,
someone reassigns ownership to app_rw — typically because the
original role has to be dropped and PostgreSQL will not drop a role that owns
anything. app_rw is now the owner of every table.
And then:
-- connected as the application, a member of app_rw
ALTER TABLE public.customers ADD COLUMN probe int;
ALTER TABLE -- it worked
DROP TABLE public.orders;
DROP TABLE -- so did this
The revoke did nothing, and it never could have.
PostgreSQL's
documentation is clear that the right to ALTER and
DROP an object is inherent in ownership and cannot be granted or
revoked. No grant is consulted. Revoking CREATE prevented
new objects and left everything already there fully exposed to the role
the application logs in as.
Three roles, not two. Ownership sits somewhere nothing logs in as.
| Role | Holds | Who is a member |
|---|---|---|
app_owner |
Owns every table, sequence, view and function. NOLOGIN. |
Nothing that logs in. Reaching its DDL needs
SET ROLE, which needs membership. |
app_rw |
SELECT, INSERT, UPDATE,
DELETE, sequence usage |
The application's service account; developers who need to write |
app_ro |
SELECT |
Everyone else. The right default. |
Now ALTER and DROP are unreachable by anything that
can log in, because the role that holds them has no members who can.
Schema migrations can no longer run as the application. This is not a gap to patch later — decide it before you cut over. Two workable answers:
LOGIN role, used by nothing but your migration job, made a member
of app_owner. The neat trick is
ALTER ROLE ddl_role IN DATABASE appdb SET role = app_owner, which
makes every session it opens run as app_owner from login.
Objects a migration creates are then owned by app_owner directly,
with no cleanup step.Letting the application create tables — "just for migrations" — means
PostgreSQL assigns ownership to the login role that created
them, which is the application's own IAM user. Not to
app_rw. So a reassignment script that moves objects owned by
app_rw reports success and moves nothing. The new tables
stay owned by the application, which holds ALTER,
DROP and TRUNCATE on them unconditionally — exactly
the defect the three-role design exists to close, reopened for precisely the
tables a migration just created.
This one is the most expensive, because it produces no error and no log line.
A single Cloud SQL instance commonly holds several databases — development and staging together, or several services. You give each its own service account, each its own key, and you have a per-environment boundary.
You do not. In PostgreSQL, pg_authid and
pg_auth_members are shared catalogs: roles and
their memberships are one set per instance. Privileges are per-database;
the roles holding them are not.
Played out as it actually happens:
app_rw to service
account A. That membership is cluster-wide.app_rw.SELECT and INSERT on
database B's data, having been named nowhere in step 2.SELECT has_table_privilege('app-a@PROJECT_ID.iam',
'public.customers', 'SELECT');
t -- in database B. Nobody granted this.
The instinct is to grant service account B on database B instead. That only makes it symmetric — now B's account reaches A's database. One role name shared across databases cannot express a per-database boundary, whichever principal you name.
app_owner_appdb, app_rw_appdb,
app_ro_appdb. Now a membership can only mean one database, because
the privileges the role holds exist in one database.
Derive the suffix inside your bootstrap script from
current_database() rather than passing it as an argument. The script
already takes its database from the connection, so it cannot be run against the
wrong one — let the role names inherit that property. There is then no argument
to mistype and no way for the roles to disagree with the database they govern.
DO $$
DECLARE
db text := current_database();
rw text := format('app_rw_%s', db);
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = rw) THEN
EXECUTE format('CREATE ROLE %I NOLOGIN', rw);
END IF;
END $$;
Do this for all three roles, including app_owner. It has no
members today, so it cannot leak today — but it is the same cluster-wide shape,
one GRANT away from the same failure, and a design whose whole point
is that shared role names were the bug should not keep one.
This is the query worth adding to your setup checklist. Run it in each database and read every column:
SELECT current_database(),
has_table_privilege('app-a@PROJECT_ID.iam', t, 'SELECT') AS a,
has_table_privilege('app-b@PROJECT_ID.iam', t, 'SELECT') AS b
FROM (SELECT format('%I.%I', schemaname, tablename) AS t
FROM pg_tables WHERE schemaname = 'public' LIMIT 1) x;
The only passing result is one true per row, on the diagonal:
appdb_a a=true b=false
appdb_b a=false b=true
A true in the wrong column is a principal reaching a database it
has no business in. Two false values usually means the grants were
never run for that database.
Data-fidelity checks all passed — they were checking that the rows arrived correctly. Nothing asked who could read the data once it was there. Confirming that an environment works proves nothing about whether it only works there. Add the negative check, or you will never see this.
PUBLIC holds USAGE on schema public
by default, so has_schema_privilege(…, 'USAGE') reads
true even after you revoke and is not evidence of reach. Always
check has_table_privilege. This has cost a wrong assertion in a
test suite that was supposed to be proving the boundary.
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_ro_appdb;
That reads like a standing rule. It is not. It grants on the tables that exist at that instant. Every table created afterwards is uncovered, and nothing reports it — you find out when a user hits a new table and cannot read it, long after whoever ran the migration has moved on.
The durable fix is
ALTER
DEFAULT PRIVILEGES, which is a standing rule:
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner_appdb IN SCHEMA public
GRANT SELECT ON TABLES TO app_ro_appdb;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner_appdb IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw_appdb;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner_appdb IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO app_rw_appdb;
Default privileges are attached to the role that creates the
object. Omit FOR ROLE and they attach to whoever runs the
statement — probably you — and do nothing for tables created later by the
migration role. Name the owner explicitly. This is trap 1 and trap 3 meeting:
it only works because you arranged for a single, known owner.
Even with defaults in place, re-run your grants script after every restore. A restore creates objects outside the path your defaults cover. Make the script idempotent so re-running it is free and boring.
Four properties fall out of it, and all four are worth being able to state:
GRANT of membership — or nothing at
all, if they are in a group that already has it.You revoked CREATE on the schema, yet the application
can still DROP tables. Why?
Ownership is not a privilege and no revoke touches it. The revoke stopped
new objects and changed nothing about the ones already there. Move
ownership to a NOLOGIN role nothing can log in as.
Two databases share an instance, each with its own service account. Why can one read the other's tables?
pg_auth_members is a shared catalog. Membership of
app_rw granted in one database is membership everywhere, and
app_rw holds privileges in both. Per-database role names are the
structural fix.
Which statement makes privileges reach tables a future migration creates?
Both ALTER DEFAULT PRIVILEGES forms look right, and only the
one naming FOR ROLE works. Defaults attach to the creating role;
without FOR ROLE they attach to whoever ran the statement, not to
the role the migration will use.
From memory: the three traps, and the fix for each.
1. Owners hold DDL unconditionally → put ownership on a
NOLOGIN role with no members that can log in.
2. Roles are cluster-wide → put the database name in the
role name, derived from current_database().
3. Grants are snapshots → ALTER DEFAULT PRIVILEGES
FOR ROLE <owner>, and re-run grants after every restore.