Topic 72

Roles and Privileges

Access Control

Postgres has one kind of principal and it is called a role. Some roles carry LOGIN and are what a connection string names; the rest exist to be granted to other roles, which is what every other system calls a group. CREATE USER is CREATE ROLE with LOGIN implied, the same catalogue object spelled differently. Every access-control design in this chapter is built from that one thing plus membership between instances of it.

Cartwheel has four roles on paper and uses two of them. cartwheel_migrator owns all nine tables in public, because Chapter 3 put it there so the migrations would have a stable owner, and it is also the role in the deployment secret that app-01 and app-02 connect with. cartwheel_analytics has never been used, because somebody pasted cartwheel_admin's password into a Slack channel in 2024 and the dashboard has authenticated with it ever since. Two consequences follow, and neither is hypothetical: an injection in the checkout path reaches DROP TABLE orders rather than a permission error, and nothing in the logs distinguishes a scheduled report from a person at a keyboard.

Roles Are Cluster-Wide

A role created without LOGIN is a group; with it, a login. Membership is a grant, and by default it is inherited, so a member holds everything the group holds from the moment it connects, without asking. NOINHERIT reverses that and forces an explicit SET ROLE before the group's privileges apply, which is worth having on the human roles that can reach administrative groups: the SET ROLE appears in the log and the ambient version does not.

One group, granted to a service and to a person
CREATE ROLE cartwheel_read NOLOGIN;              -- a group, cannot connect
GRANT USAGE  ON SCHEMA analytics TO cartwheel_read;
GRANT SELECT ON ALL TABLES IN SCHEMA analytics TO cartwheel_read;

GRANT cartwheel_read TO cartwheel_analytics;     -- the dashboard
GRANT cartwheel_read TO nadia;                   -- and one human, individually

Roles are not database objects. They live in a shared catalogue beside databases, tablespaces and replication slots, so cartwheel_read created while connected to cartwheel is visible from every database on pg-primary, and \du prints the same list wherever it runs. Privileges are the opposite: a grant on a table is recorded with that table, in that database. The split is why a role can exist everywhere and still read nothing anywhere, and why the answer to "the analytics team can connect but sees no tables" is never to create the role again.

Ownership Is Not a Privilege

The owner of a table can drop it, rewrite it, change its column types and hand it to somebody else. None of that is a privilege in the GRANT sense: the manual is explicit that the right to modify or destroy an object is inherent in being its owner and cannot be granted or revoked in itself. There is no REVOKE DROP. The only way to stop the role in the application's connection string from dropping orders is for that role not to own orders.

What the application actually needs, and nothing beyond it
-- run as cartwheel_migrator, which owns the nine tables in public
GRANT USAGE ON SCHEMA public TO cartwheel_app;
GRANT SELECT, INSERT, UPDATE, DELETE
  ON ALL TABLES IN SCHEMA public TO cartwheel_app;
GRANT USAGE ON SEQUENCE orders_id_seq TO cartwheel_app;

That is four verbs and one sequence, with the schema itself out of reach. cartwheel_app gets no TRUNCATE, no TRIGGER, no REFERENCES, and no route to DDL of any kind. The sequence grant is the line people leave out and then debug for an hour: a column whose default calls nextval() needs USAGE on that sequence, so without it every insert into orders fails with a permission error naming an object the statement never mentioned. Making the change at Cartwheel is one value in a secret and a rolling restart of app-01 and app-02: the design takes fifteen minutes, and the deployment is the part that needs a window.

Four roles, and the one thing each of them is allowed to be
cartwheel_migrator
Owns all nine tables in public. Dropping and rewriting them is inherent in that ownership, and no REVOKE takes it back.
cartwheel_app
Four DML verbs and USAGE on the sequence. No TRUNCATE, no TRIGGER, no REFERENCES, no route to DDL of any kind.
cartwheel_analytics
The dashboard, reading through the cartwheel_read group rather than through a privilege of its own.
nadia, and every other human
An individual login granted that same group, so the audit trail can still separate a scheduled report from a person at a keyboard.

The Grant Chain People Forget

Most permission tickets fall into one of two gaps. The first is that reaching a table needs USAGE on its schema as well as a privilege on the table, so a perfectly correct GRANT SELECT still answers "permission denied" when the schema grant is missing. The second is larger. GRANT … ON ALL TABLES IN SCHEMA is not a standing rule; it is a loop over the tables that exist at that instant, and every table a later migration creates arrives with no grants on it at all.

The rule that covers tomorrow's tables
ALTER DEFAULT PRIVILEGES FOR ROLE cartwheel_migrator IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO cartwheel_app;

ALTER DEFAULT PRIVILEGES FOR ROLE cartwheel_migrator IN SCHEMA analytics
  GRANT SELECT ON TABLES TO cartwheel_read;

FOR ROLE is the load-bearing clause. Default privileges attach to the role that creates the object, not to the schema in general, so those two statements fire only for objects cartwheel_migrator makes; a table created by cartwheel_admin during an incident gets nothing and the ticket comes back a week later. They are also not inherited through membership: at creation time only the current role's own defaults apply. Both facts point the same way: this belongs in the migration tooling, which always runs as one known role, rather than in a runbook somebody follows by hand. The same statement shape covers sequences, functions, types and schemas.

The chain one insert has to clear, and the grant that covers tomorrow's tables
USAGE on the schemawithout it the table grant is unreachable
The verbs on the tableSELECT, INSERT, UPDATE, DELETE and nothing structural
USAGE on the sequencethe line people leave out, then debug for an hour
ALTER DEFAULT PRIVILEGESFOR ROLE cartwheel_migrator, in the migration tooling

Predefined Roles Instead of Superuser

Every predefined role exists to remove one specific reason people had for granting superuser. pg_monitor is the one to know: it is a member of pg_read_all_settings, pg_read_all_stats and pg_stat_scan_tables, which together are exactly what a metrics exporter or a human debugging a slow Saturday needs and nothing more. pg_read_all_data and pg_write_all_data grant reads or writes across every table as though the privilege had been granted individually, plus USAGE on every schema, and neither of them bypasses row-level security, which becomes the point of the topic two pages from here.

Two grants that used to be spelled "superuser"
GRANT pg_monitor TO nadia;      -- every setting, every pg_stat_* view

GRANT MAINTAIN ON analytics.daily_orders TO cartwheel_analytics;

The second line is newer than most role setups. Since 17 there is a MAINTAIN privilege, and a pg_maintain role that carries it across every relation, covering VACUUM, ANALYZE, CLUSTER, REINDEX, REFRESH MATERIALIZED VIEW and LOCK TABLE. Before that, refreshing a materialized view required owning it, which is how nightly refresh jobs ended up running as the schema owner or as something worse. The metrics exporter gets its own login role holding pg_monitor and nothing else; pg_signal_backend is the other habit-breaker, letting a role cancel or terminate somebody else's session without any further administrative power, and deliberately stopping short of a superuser's backend.

PUBLIC, and What 15 Changed

PUBLIC is not a role you can drop. Every role is implicitly a member of it, and Postgres grants a small fixed set of privileges to it whenever an object is created: CONNECT and TEMPORARY on databases, EXECUTE on functions and procedures, USAGE on languages and data types. Tables, columns, sequences, schemas, tablespaces and configuration parameters get nothing by default, so these grants stay invisible until one of the three that do exist causes a problem.

Closing the defaults deliberately at cluster setup
REVOKE CONNECT ON DATABASE cartwheel FROM PUBLIC;
GRANT  CONNECT ON DATABASE cartwheel
    TO cartwheel_app, cartwheel_analytics, cartwheel_migrator;

REVOKE CREATE ON SCHEMA public FROM PUBLIC;   -- default since 15; check anyway

The function default is the sharpest of the three. A SECURITY DEFINER function created by cartwheel_admin is callable by every role in the cluster the moment it commits, so the matching REVOKE EXECUTE belongs in the migration that creates it, not in an audit eighteen months later. The database default is worth closing at setup, so that reaching cartwheel at all is a list of three roles rather than everyone who can authenticate. CREATE on the public schema was the fourth and stopped being granted by default in 15 — but a cluster upgraded from 14 or earlier still carries it, and the last statement is what removes it.

Dropping a Role

A role that owns anything cannot be dropped, and neither can one that still holds a privilege somewhere. That is the whole reason departed staff and retired integrations accumulate in the catalogue for years: the first DROP ROLE fails with a dependency message, the ten spare minutes never arrive, and the login stays valid. The sequence that works is two statements before the drop, repeated in every database that contains any of the role's objects.

Retiring the login a decommissioned reporting job used
-- connected to cartwheel; repeat in every database it owns objects in
REASSIGN OWNED BY cartwheel_report TO cartwheel_migrator;
DROP OWNED BY cartwheel_report;   -- privileges and default privileges too
DROP ROLE cartwheel_report;

REASSIGN OWNED moves ownership of the role's objects in the current database, plus the shared ones, meaning databases and tablespaces, to the new owner. It deliberately does not touch privileges the role was granted on objects it never owned, nor the default privileges it created, and DROP OWNED is what clears those. Miss a database and the final DROP ROLE fails pointing at a dependency in a database the session was never connected to, which is exactly the failure that ended the first attempt.

Password policy is the piece Postgres does not have. VALID UNTIL sets an expiry on the password and not on the role itself, and it is not enforced at all when the login uses a non-password method; there is no history, no complexity rule, no forced rotation. On a self-managed cluster that gap is closed by pushing authentication outward, to certificates or Kerberos or an identity provider, which is where the next topic begins, with the trust line still sitting in Cartwheel's pg_hba.conf.

Application as owner vs application as a limited role

The application as owner — one connection string, no permission errors, and every migration works first time. It also means an injected DROP TABLE orders succeeds, a mistyped deploy script can rewrite a column type under Saturday load, and any row-level security added later is silently inert.

The application as a limited role — four DML verbs and nothing structural. The cost is one extra role, an ALTER DEFAULT PRIVILEGES line in the migration tooling, and the discipline of never running DDL from the application process. The benefit is that the worst outcome of an injection becomes bad data rather than no schema.

Why Cartwheel takes the second — and the deciding argument is not the injection. Policies do not apply to a table's owner, so the entire row-level-security design later in this chapter rests on the application not connecting as cartwheel_migrator. Get the roles wrong and that feature is decoration.

Common Mistakes
  • Connecting the application as the schema owner or as a superuser — an injection or a mistyped migration escalates from a bad row to a dropped table, and no REVOKE exists that would have prevented it.
  • Granting on existing tables and skipping ALTER DEFAULT PRIVILEGES — every table the next migration creates arrives unreadable, and somebody hand-grants it days later after a support ticket.
  • Running ALTER DEFAULT PRIVILEGES as cartwheel_admin without FOR ROLE cartwheel_migrator — the defaults attach to the admin's future objects, and the migrator's tables still arrive with nothing on them.
  • Granting superuser to the metrics exporter because one view came back empty — pg_monitor covers every pg_stat_* view and every setting, and it cannot drop a table or read the heap.
  • Sharing one login between the dashboard and the analytics team — pg_stat_activity and the audit trail can no longer separate a scheduled query from a human exploring production.
  • Abandoning DROP ROLE at the first dependency error — the role survives, its password stays valid, and a departed contractor keeps a working credential indefinitely.
Best Practices
  • Give the migrator ownership, the application four DML verbs, analytics SELECT through a group, and every human an individual login that inherits from that group.
  • Put ALTER DEFAULT PRIVILEGES FOR ROLE cartwheel_migrator into the migration tooling, so grants follow new tables without anyone having to remember them.
  • Grant pg_monitor to monitoring, and pg_maintain or a per-table MAINTAIN to maintenance jobs on 17 and later, instead of ownership or superuser.
  • Revoke CONNECT on the database from PUBLIC at cluster setup and grant it to named roles, and revoke EXECUTE from PUBLIC in the same migration that creates a SECURITY DEFINER function.
  • Script the role lifecycle as REASSIGN OWNED, DROP OWNED, DROP ROLE, run once per database, so removing a leaver is a command rather than a project.
  • Set NOINHERIT on human roles that are members of administrative groups, so reaching that power requires an explicit SET ROLE somebody can see in the log.
Comparable toolsMySQL users bound to host patterns, roles only since 8.0Oracle users, roles and profiles with password policy built inSQL Server server logins mapped to per-database usersLDAP and Kerberos where password policy actually belongs

Knowledge Check

Why can Cartwheel not simply revoke the application's ability to drop the orders table?

  • Dropping is inherent in ownership and cannot be revoked on its own
  • A DROP privilege exists but is granted implicitly to every role member
  • Revoking it works only once the table has been reassigned to PUBLIC
  • Only a superuser may revoke it, and the migrator is not a superuser

A migration adds a table and the dashboard cannot read it, although GRANT SELECT ON ALL TABLES ran a year ago. Why?

  • A new table inherits no privileges until it has been analyzed once
  • That grant only covered the tables that existed at the moment it ran
  • Schema USAGE is revoked automatically whenever a table is added
  • New tables belong to PUBLIC until an explicit owner is assigned

Which grant does a metrics exporter need so that it never has to be a superuser?

  • pg_read_all_data, the only role that exposes the statistics views
  • pg_signal_backend, which carries read access to the monitoring views
  • pg_monitor, which covers every setting and every pg_stat_* view
  • pg_stat_scan_tables alone, which includes every configuration setting

What does REASSIGN OWNED leave behind that DROP OWNED then removes?

  • Ownership of the shared objects, such as databases and tablespaces
  • Privileges it holds on objects it never owned, and its default privileges
  • Tables that it created in any other schema of the same database, left orphaned
  • The role's password and the VALID UNTIL expiry attached to it

On a database created fresh on PostgreSQL 18, what does PUBLIC still hold by default?

  • CREATE on the public schema, as it has held since the first release
  • CONNECT and TEMPORARY on the database, and EXECUTE on new functions
  • SELECT on every table in the public schema until it is revoked
  • USAGE on every schema in the database, but no rights on the tables

You got correct