PostgreSQLV · Authentication, Roles and TLSAuthorisation
Ownership, default privileges, and the permission disaster they cause
What you'll learn
- Explain why GRANT ON ALL TABLES does not cover future tables
- Configure ALTER DEFAULT PRIVILEGES correctly, including the FOR ROLE clause
- Diagnose a permission failure that appeared after a deployment
- Describe what object ownership confers beyond privileges
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
The commonest permission failure in PostgreSQL is not a missing grant. It is a grant that was correct when it was made and does not cover the objects created since.
The demonstration
A schema with a read-only group, granted SELECT on all tables. Then a
new table is created.
$ psql -U postgres # GRANT, CREATE TABLE, then check bothGRANT SELECT ON ALL TABLES IN SCHEMA app TO app_ro;
CREATE TABLE app.created_later(id int);
tbl | app_ro_can_select
---------------+-------------------
existing | t
created_later | f
(2 rows)ALL TABLES is not a standing instruction. It expands, at the moment
the statement runs, to the list of tables that exist, and grants on
each of them. A table created a second later is not covered, and
nothing reports this.
The fix, and its trap
ALTER DEFAULT PRIVILEGES records a rule applied whenever an object is
created.
$ psql -U postgres # ALTER DEFAULT PRIVILEGES, then CREATE TABLEALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
GRANT SELECT ON TABLES TO app_ro;
CREATE TABLE app.created_after_default(id int);
tbl | app_ro_can_select
-----------------------+-------------------
created_after_default | t
(1 row)The trap is in the FOR ROLE clause. Default privileges are recorded
per creating role, and apply only when that role creates an
object.
$ psql -U postgres -c 'SELECT defaclrole::regrole AS for_role, defaclnamespace::regnamespace AS in_schema, defaclobjtype AS objtype, defaclacl FROM pg_default_acl' for_role | in_schema | objtype | defaclacl
-----------+-----------+---------+----------------------
app_owner | app | r | {app_ro=r/app_owner}
(1 row)Omitting FOR ROLE records the rule for the role running the
statement, which is usually a superuser or a DBA — and if migrations
actually run as app_owner, the rule never fires.
Covering the ground properly
A complete setup for one schema needs both halves, and needs sequences as well as tables:
-- Retrospective: everything that exists now
GRANT USAGE ON SCHEMA app TO app_ro, app_rw;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_ro;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app TO app_rw;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO app_rw;
-- Prospective: everything app_owner creates from now on
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
GRANT SELECT ON TABLES TO app_ro;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner IN SCHEMA app
GRANT USAGE, SELECT ON SEQUENCES TO app_rw;
Three things that are easy to omit:
USAGE ON SCHEMA is a prerequisite for every table privilege
beneath it. Without it, table grants exist and are unusable, and the
error is the same permission denied — this is the other half of the
diagnosis in the previous lesson.
Sequences back every GENERATED or serial column. A role with
INSERT on a table and nothing on its sequence cannot insert, and the
error names the sequence rather than the table, which sends people
looking in the wrong place.
Default privileges are per schema. A rule for app does nothing
for a schema added later, so creating a schema is also a permissions
task.
What ownership confers
Ownership is not a privilege and cannot be granted with GRANT. The
owner of an object may always ALTER it, DROP it, and change its
privileges, regardless of what has been granted or revoked. Revoking
everything from an owner does not stop them.
This is why the previous lesson’s pattern puts ownership on a NOLOGIN
role: the ability to drop a table should not be attached to a
credential that an application holds.
-- Move existing objects to the intended owner
REASSIGN OWNED BY old_role TO app_owner;
-- Then the old role can be dropped, once it owns nothing
DROP OWNED BY old_role;
DROP ROLE old_role;
DROP ROLE fails while the role owns anything or holds any privilege,
which is a useful safety property and a frequent surprise during
decommissioning. REASSIGN OWNED and DROP OWNED are the two
statements that clear the way, and they operate per database — they
must be run in each database where the role owns something.
Production discipline
- Pair every
GRANT ON ALLwith anALTER DEFAULT PRIVILEGES. The first covers the past, the second the future. - Always write
FOR ROLE, naming the role that actually creates objects. Omitting it records the rule for whoever ran the statement, which is usually not the migration role. - Audit
pg_default_aclagainst your deployment pipeline. A creating role missing from that list is a future incident. - Grant
USAGE ON SCHEMAand sequence privileges too. Both producepermission deniederrors that point somewhere unhelpful. - Keep ownership on a
NOLOGINrole. The owner can always drop the object, whatever has been revoked. - Use
REASSIGN OWNEDandDROP OWNEDper database when decommissioning a role, sinceDROP ROLErefuses while anything is owned.
Cross-course references
- Git, CI/CD & GitOps — Part CVI (Change management) covers making a migration’s permission consequences part of its review, which is where the default-privileges gap is cheapest to catch.
- Secrets, PKI & Certificate Management — Part I (Foundations) covers blast-radius reasoning, which is the argument for separating ownership from the credentials an application holds.
- Ansible for Production Sysadmins — Part XII (Idempotency) covers expressing these grants as a desired state rather than as one-time statements.
Quiz
Knowledge check · 6 questions
Q1. A reporting role could read every table on Monday. After Tuesday's migration it cannot read three new tables. No grants were revoked. What happened?
Q2. A DBA sets default privileges while connected as postgres, tests by creating a table as postgres, and confirms the reporting role can read it. Migrations run as app_owner and the problem recurs. Why?
Q3. Which are commonly omitted from a schema permission setup and produce a permission denied error? Select all that apply.
Q4. Revoking all privileges from an object's owner prevents the owner from dropping it.
Q5. Explain the difference between a null access control list on a table and one that exists but omits a given role.
Q6. Diagnose and give the durable fix.
A nightly reporting job has failed on the first of the month for four consecutive months, always with permission denied on a table whose name contains the previous month. Each time, an engineer grants SELECT on that table to the reporting role and the job succeeds. The application creates a new partition table each month through a migration that connects as the role deploy_bot. A DBA reports having configured default privileges for this schema last year.
Passing score: 75%. Answers are checked in this browser.