Roles and privileges d5
- PostgreSQL with PostGIS
- 1.5 hours
Prerequisites: Transactions (d4)
Give a reporting tool exactly the access it needs: roles, membership, owners, GRANT and REVOKE, default privileges, least privilege and a taste of row-level security. Everything runs in your own PostgreSQL, on the lab tables (the setup page).
Concept
A database server decides who may do what with roles and privileges:
- Roles. PostgreSQL has one kind of account, the role. A role with the
LOGINattribute is what other systems call a user; one without it is a group. Roles belong to the whole server (the cluster), not to one database: a role made while you work inlearnexists in every database there, and outliveslearn_curriculum. Other attributes say what a role may do beyond the objects it's granted:SUPERUSER(everything, every check skipped),CREATEDB,CREATEROLE,BYPASSRLS. - Membership.
GRANT learn_reporting TO learn_powerbimakes one role a member of another. A member uses the group's privileges directly (INHERIT) and may switch to it withSET ROLE(SET). Both are on by default, and since PostgreSQL 16 both are options of each grant:GRANT learn_reporting TO learn_powerbi WITH INHERIT FALSEmakes a member that has the group's privileges only afterSET ROLE, andWITH SET FALSEone that uses them but can never act as the group. So privileges go to a group, and each person or program gets a login that is a member: adding a dashboard is oneGRANT, and removing it touches nothing else. - Owners. Every table, schema and function has an owner, the role that made it (or that
ALTER … OWNER TOnames). The owner can do anything to it, including drop it and grant on it; everyone else gets only what is granted. You own the lab: you loadedlearn_curriculum. - Privileges are granted per object, and a reader needs one at every level:
CONNECTon the database,USAGEon the schema (to look up names in it), thenSELECTon each table. Writing isINSERT,UPDATE,DELETEandTRUNCATE; making tables isCREATEon the schema.GRANT SELECT (id, slug) ON speciesgrants columns only.REVOKEtakes a privilege back. - PUBLIC is every role, present and future. By default PUBLIC may connect to a database and make temporary tables in it, use (but since PostgreSQL 15 not create in) schema
public, and run every function. So a new role can already connect and call PostGIS; to keep it out of a database, revokeCONNECTfrom PUBLIC on that database and grant it to the roles that need it. These are the defaults of a new database, not facts about yours: a database upgraded from PostgreSQL 14 or older keeps PUBLIC'sCREATEonpublic, and an administrator may have changed any of them. Read the actual privileges (\dn+,\l, orhas_schema_privilege('public', 'public', 'CREATE')) instead of inferring them from the version. - Schemas are folders of tables, views and functions inside one database. They group objects by purpose and by who may touch them: the lab in
learn_curriculum, your own work in a schema of yours (a reload replaceslearn_curriculum, as the setup page says), reports in a third. Thesearch_path(set by the setup page's session block) is the list of schemas a plain table name is looked up in. Anyone who can create objects in a schema on yoursearch_pathcan put a table or function there that shadows one you meant; that is why PostgreSQL 15 stopped letting PUBLIC create inpublic. - Default privileges.
GRANT SELECT ON ALL TABLES IN SCHEMA sgrants on the tables that exist now; a table made tomorrow is covered by nothing.ALTER DEFAULT PRIVILEGES FOR ROLE maker IN SCHEMA s GRANT SELECT ON TABLES TO readerrecords a grant that PostgreSQL adds to every tablemakercreates insfrom then on. Only tables made by that role: the defaults belong to the creator, not to the schema. - Least privilege: each role gets exactly what its job needs. A reporting tool reads; it doesn't write, make tables, or see schemas outside its job. If its password leaks, that is all the leak gives away.
- Removing a role.
DROP ROLErefuses while anything depends on the role: objects it owns, privileges granted to it, default privileges, policies.DROP OWNED BY rdrops whatrowns in the current database and revokes everything granted to it there;REASSIGN OWNED BY r TO otherhands its objects over instead. Both work on one database at a time, so on a server where the role has privileges in several, run it in each. - Row-level security (RLS) filters rows per role.
ALTER TABLE t ENABLE ROW LEVEL SECURITY, then a policy,CREATE POLICY name ON t FOR SELECT TO role USING (condition), adds the condition to every query that role runs ont, so it sees only matching rows, with no view or application code to forget. Once RLS is on, a role with no policy for a command sees no rows at all (default deny).USINGfilters the rows a command reads or changes;WITH CHECKtests new rows (INSERT,UPDATE). Policies for the same command are combined withOR, unless createdAS RESTRICTIVE(AND). The table's owner, superusers andBYPASSRLSroles skip the policies;FORCE ROW LEVEL SECURITYapplies them to the owner too.
How to look: in psql, \du lists roles and their attributes, \drg (psql 16+) memberships with their options, \dn+ schemas with their privileges, \dp tables' privileges and policies, \ddp default privileges. In SQL, has_table_privilege(role, table, privilege) and its siblings (has_schema_privilege, has_database_privilege, has_function_privilege) answer for any role, and information_schema.role_table_grants lists table grants.
These labs create roles, so they need the CREATEROLE privilege. The postgres superuser has it. On your own install, as another user, run once as the superuser: ALTER ROLE <you> CREATEROLE; and, on PostgreSQL 16 and later, ALTER ROLE <you> SET createrole_self_grant = 'set, inherit';, then reconnect. Since PostgreSQL 16, a non-superuser who creates a role may administer it but not act as it: without the second setting, the labs' SET ROLE, ALTER DEFAULT PRIVILEGES FOR ROLE and DROP OWNED BY are refused. (Tested on PostgreSQL 16 as a login with only those two settings.)
Every lab below is one transaction, typed exactly as shown in psql, BEGIN to ROLLBACK, with ON_ERROR_STOP off as in Transactions, d4: a statement marked -- fails: fails on purpose, and the savepoint before it lets the transaction go on. Each lab drops its roles before the ROLLBACK anyway: that is the cleanup a script you COMMIT on a real server needs, and roles, being the server's, are the thing most easily left behind.
Worked example
How do I give a reporting tool read-only access to one schema and nothing else? Make a group role that holds the privileges, learn_reporting, and a login for the tool, learn_powerbi, that is a member of it. The group gets USAGE on learn_curriculum and SELECT on its tables; nothing else. Then act as the tool: reading works, writing and making tables don't.
Checked 2026-11-02 on PostgreSQL 16.15, PostGIS 3.4.2, GEOS 3.12.1, GDAL 3.8.4.
d5-reporting · PostgreSQL with PostGIS
BEGIN;
CREATE ROLE learn_reporting NOLOGIN;
GRANT USAGE ON SCHEMA learn_curriculum TO learn_reporting;
GRANT SELECT ON ALL TABLES IN SCHEMA learn_curriculum TO learn_reporting;
CREATE ROLE learn_powerbi LOGIN IN ROLE learn_reporting;
SET ROLE learn_powerbi;
SAVEPOINT try;
-- fails: permission denied for table species
UPDATE species SET common_name = 'Devil' WHERE id = 48;
ROLLBACK TO SAVEPOINT try;
-- fails: permission denied for schema learn_curriculum
CREATE TABLE my_report (id integer);
ROLLBACK TO SAVEPOINT try;
SELECT current_user AS reading_as, count(*) AS records, count(DISTINCT species_id) AS species
FROM occurrences;
RESET ROLE;
DROP OWNED BY learn_powerbi, learn_reporting;
DROP ROLE learn_powerbi, learn_reporting;
ROLLBACK;
-- result: d5-reporting · 1 row · sha256 df4ba6a052e1 reading_as | records | species learn_powerbi | 40395 | 71
In d5.sql, with the blocks it runs after.
learn_powerbi holds no privilege of its own: it reads through its membership in learn_reporting. SET ROLE is how you test a role without its password; a superuser may switch to any role, and you, with createrole_self_grant, to those you made. For the real tool, give the login a password with psql's \password learn_powerbi (it isn't echoed or logged), and put the server's address, the database and that login in the tool's connection settings (Power Query, p1).
Lab
What the reporting role may do, as the server sees it.
has_…_privilegeanswers for any role, so you needn't switch to it:d5-privileges · PostgreSQL with PostGIS
BEGIN; CREATE ROLE learn_reporting NOLOGIN; GRANT USAGE ON SCHEMA learn_curriculum TO learn_reporting; GRANT SELECT ON ALL TABLES IN SCHEMA learn_curriculum TO learn_reporting; SELECT object, privilege, allowed FROM ( SELECT 1 AS level, 'this database' AS object, p AS privilege, has_database_privilege('learn_reporting', current_database(), p) AS allowed FROM unnest(ARRAY['CONNECT', 'TEMPORARY', 'CREATE']) AS p UNION ALL SELECT 2, 'schema ' || s, p, has_schema_privilege('learn_reporting', s, p) FROM unnest(ARRAY['learn_curriculum', 'public']) AS s, unnest(ARRAY['USAGE', 'CREATE']) AS p UNION ALL SELECT 3, 'table species', p, has_table_privilege('learn_reporting', 'species', p) FROM unnest(ARRAY['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE']) AS p UNION ALL SELECT 4, 'function ST_Area(geometry)', 'EXECUTE', has_function_privilege('learn_reporting', 'ST_Area(geometry)', 'EXECUTE') ) AS checked ORDER BY level, object, privilege; DROP OWNED BY learn_reporting; DROP ROLE learn_reporting; ROLLBACK;-- result: d5-privileges · 13 rows · sha256 18b775de531d object | privilege | allowed this database | CONNECT | false this database | CREATE | false this database | TEMPORARY | false schema learn_curriculum | CREATE | false schema learn_curriculum | USAGE | true … 8 more rows
In d5.sql, with the blocks it runs after.
The two grants show as
SELECTon the table andUSAGEonlearn_curriculum. The othertrues come from PUBLIC:USAGEonpublicand every function. The database rows are this server's answer, read from its ACLs: critwire's test database grantsCONNECTandTEMPORARYonly to named roles, so the role has neither. In a new database of yours PUBLIC has both, and the same block showstruefor them; likewise thefalseforCREATEonpublicholds only where PUBLIC'sCREATEis gone (a new database since PostgreSQL 15, or revoked by hand). So run the block on the server that matters instead of reasoning from the defaults. PUBLIC's privileges are harmless for reading, but "nothing else" also means other databases on the server: there, revokeCONNECTfrom PUBLIC, as critwire's test database does. As the database's owner, on a server you run (critwire's tests never change a database's privileges, so this block isn't run here;learnis your lab database):d5-database · PostgreSQL with PostGIS
REVOKE CONNECT, TEMPORARY ON DATABASE learn FROM PUBLIC; GRANT CONNECT ON DATABASE learn TO learn_reporting; ALTER ROLE learn_powerbi SET default_transaction_read_only = on;Not run here: changes the database's own privileges, which the tests never do.
In d5.sql, with the blocks it runs after.
The last line is a guard against mistakes, not a lock: every session of that login starts read-only, so a privilege granted by mistake doesn't turn into a write, but the session itself may switch it off. Settings like this belong to the login; members don't inherit them from a group.
Default privileges. The loading job,
learn_etl, owns a schema of report tables,learn_reports, and the reporting role reads them. Grant on all tables, make another, then set default privileges and make a third:d5-defaults · PostgreSQL with PostGIS
BEGIN; CREATE ROLE learn_etl NOLOGIN; CREATE ROLE learn_reporting NOLOGIN; GRANT USAGE ON SCHEMA learn_curriculum TO learn_etl; GRANT SELECT ON occurrences TO learn_etl; CREATE SCHEMA learn_reports AUTHORIZATION learn_etl; GRANT USAGE ON SCHEMA learn_reports TO learn_reporting; SET ROLE learn_etl; CREATE TABLE learn_reports.report_1_by_year AS SELECT year, count(*) AS records FROM occurrences WHERE year IS NOT NULL GROUP BY year; GRANT SELECT ON ALL TABLES IN SCHEMA learn_reports TO learn_reporting; CREATE TABLE learn_reports.report_2_by_species AS SELECT species_id, count(*) AS records FROM occurrences GROUP BY species_id; RESET ROLE; ALTER DEFAULT PRIVILEGES FOR ROLE learn_etl IN SCHEMA learn_reports GRANT SELECT ON TABLES TO learn_reporting; SET ROLE learn_etl; CREATE TABLE learn_reports.report_3_by_country AS SELECT country_code, count(*) AS records FROM occurrences GROUP BY country_code; RESET ROLE; SELECT tablename AS table_name, tableowner AS owner, has_table_privilege('learn_reporting', format('%I.%I', schemaname, tablename), 'SELECT') AS reporting_can_read FROM pg_tables WHERE schemaname = 'learn_reports' ORDER BY tablename; DROP SCHEMA learn_reports CASCADE; DROP OWNED BY learn_etl, learn_reporting; DROP ROLE learn_etl, learn_reporting; ROLLBACK;-- result: d5-defaults · 3 rows · sha256 637b64b91525 table_name | owner | reporting_can_read report_1_by_year | learn_etl | true report_2_by_species | learn_etl | false report_3_by_country | learn_etl | true
In d5.sql, with the blocks it runs after.
The grant on all tables covered the one table that existed; the second was made after it and nobody can read it, the usual "the dashboard broke after last night's load". The third was covered by the default privileges, because
learn_etlmade it. Had you made it yourself, they wouldn't apply: name the role that really creates the tables inFOR ROLE.\ddplists the defaults; the fix for tables already missed is one moreGRANT … ON ALL TABLES.DROP SCHEMA … CASCADEremoves the reports;DROP OWNED BY learn_etlwould have removed them too, since it owns them.Why
DROP ROLEalone fails. A role that was granted anything can't be dropped:d5-drop-role · PostgreSQL with PostGIS
BEGIN; CREATE ROLE learn_reporting NOLOGIN; GRANT USAGE ON SCHEMA learn_curriculum TO learn_reporting; GRANT SELECT ON species, occurrences TO learn_reporting; SAVEPOINT before_drop; -- fails: role "learn_reporting" cannot be dropped because some objects depend on it DROP ROLE learn_reporting; ROLLBACK TO SAVEPOINT before_drop; SELECT table_name, privilege_type FROM information_schema.role_table_grants WHERE grantee = 'learn_reporting' ORDER BY table_name; DROP OWNED BY learn_reporting; DROP ROLE learn_reporting; ROLLBACK;-- result: d5-drop-role · 2 rows · sha256 6be36802af13 table_name | privilege_type occurrences | SELECT species | SELECT
In d5.sql, with the blocks it runs after.
The error's detail lists what holds the role:
privileges for schema learn_curriculum, then each table.DROP OWNED BYrevokes them all at once; listing them by hand and revoking each works too, but misses what you didn't list.A taste of row-level security. Two partners, one in Australia and one in Brazil, may each read only the records from their country; a third role has
SELECTbut no policy. Each role writes what it sees into a temporary table (grantedINSERTfor this), so one query can compare them:d5-rls · PostgreSQL with PostGIS
BEGIN; CREATE ROLE learn_partner_au NOLOGIN; CREATE ROLE learn_partner_br NOLOGIN; CREATE ROLE learn_no_policy NOLOGIN; GRANT USAGE ON SCHEMA learn_curriculum TO learn_partner_au, learn_partner_br, learn_no_policy; GRANT SELECT ON occurrences TO learn_partner_au, learn_partner_br, learn_no_policy; ALTER TABLE occurrences ENABLE ROW LEVEL SECURITY; CREATE POLICY partner_au ON occurrences FOR SELECT TO learn_partner_au USING (country_code = 'AU'); CREATE POLICY partner_br ON occurrences FOR SELECT TO learn_partner_br USING (country_code = 'BR'); CREATE TEMPORARY TABLE seen (reader text PRIMARY KEY, records bigint, species bigint, countries bigint); GRANT INSERT ON seen TO learn_partner_au, learn_partner_br, learn_no_policy; INSERT INTO seen SELECT '(you, the owner)', count(*), count(DISTINCT species_id), count(DISTINCT country_code) FROM occurrences; SET ROLE learn_partner_au; INSERT INTO seen SELECT current_user, count(*), count(DISTINCT species_id), count(DISTINCT country_code) FROM occurrences; SET ROLE learn_partner_br; INSERT INTO seen SELECT current_user, count(*), count(DISTINCT species_id), count(DISTINCT country_code) FROM occurrences; SET ROLE learn_no_policy; INSERT INTO seen SELECT current_user, count(*), count(DISTINCT species_id), count(DISTINCT country_code) FROM occurrences; RESET ROLE; SELECT reader, records, species, countries FROM seen ORDER BY records DESC, reader; DROP OWNED BY learn_partner_au, learn_partner_br, learn_no_policy; ALTER TABLE occurrences DISABLE ROW LEVEL SECURITY; DROP ROLE learn_partner_au, learn_partner_br, learn_no_policy; ROLLBACK;-- result: d5-rls · 4 rows · sha256 391fc1e17327 reader | records | species | countries (you, the owner) | 40395 | 71 | 106 learn_partner_br | 5033 | 14 | 1 learn_partner_au | 4636 | 5 | 1 learn_no_policy | 0 | 0 | 0
In d5.sql, with the blocks it runs after.
The same query,
count(*) FROM occurrences, gives each role a different answer, and none of them asked for a country. You, the owner, skip the policies and see every record. The role withSELECTand no policy sees nothing: with RLS on, no policy means no rows.DROP OWNED BYdropped both policies, since each named only its role. A policy can also read a table,USING (country_code IN (SELECT country_code FROM partner_countries WHERE role_name = current_user)), so one policy serves every partner; index the columns it filters on, because it runs inside every query.
Result
A group role with USAGE on one schema and SELECT on its tables, and a login that is its member, reads the 40,395 records and is refused writing and making tables. Its other abilities come from PUBLIC (public and functions, plus connecting and temporary tables where the database hasn't revoked them, as critwire's test database has), so read the actual ACLs; a server you run can revoke them per database. GRANT … ON ALL TABLES covers only tables that exist; default privileges cover the tables a named role makes later. DROP ROLE fails while the role holds privileges, and DROP OWNED BY clears them. With row-level security, the Australian partner saw 4,636 records, the Brazilian 5,033, a role without a policy none, and the owner all of them.
Exercise
The Tasmanian devil project's dashboard needs a role, learn_devil_dashboard, that reads the species list and the devil's records (species 48), and nothing else: no other species' records, no other lab table, no writing. Create it, prove that reading datasets and updating a record are refused, and show what it sees in occurrences and species. Clean up as the labs do.
Show answer
d5-ex · PostgreSQL with PostGIS
BEGIN;
CREATE ROLE learn_devil_dashboard NOLOGIN;
GRANT USAGE ON SCHEMA learn_curriculum TO learn_devil_dashboard;
GRANT SELECT ON species, occurrences TO learn_devil_dashboard;
ALTER TABLE occurrences ENABLE ROW LEVEL SECURITY;
CREATE POLICY devil_only ON occurrences FOR SELECT TO learn_devil_dashboard USING (species_id = 48);
SET ROLE learn_devil_dashboard;
SAVEPOINT try;
-- fails: permission denied for table datasets
SELECT count(*) FROM datasets;
ROLLBACK TO SAVEPOINT try;
-- fails: permission denied for table occurrences
UPDATE occurrences SET month = 7 WHERE id = 6004132305;
ROLLBACK TO SAVEPOINT try;
SELECT count(*) AS records, count(DISTINCT species_id) AS species_with_records,
(SELECT count(*) FROM species) AS species_rows
FROM occurrences;
RESET ROLE;
DROP OWNED BY learn_devil_dashboard;
ALTER TABLE occurrences DISABLE ROW LEVEL SECURITY;
DROP ROLE learn_devil_dashboard;
ROLLBACK;
-- result: d5-ex · 1 row · sha256 bae58d1ab786 records | species_with_records | species_rows 2000 | 1 | 76
In d5.sql, with the blocks it runs after.
Check
The answer: USAGE on the schema and SELECT on the two tables; a policy limits occurrences to species 48. species has no policy and no RLS, so all its rows are readable.
As your role, occurrences shows 2,000 records of 1 species and species all 76 rows; reading datasets and the UPDATE both fail with permission denied; after your block, SELECT count(*) FROM pg_roles WHERE rolname LIKE 'learn\_%' is 0 and \dp occurrences shows no policy. If datasets was readable, you granted ON ALL TABLES; if the role saw 40,395 records, RLS isn't on (or you checked as yourself, the owner, instead of after SET ROLE).
Dialects
- PostgreSQL: roles are server-wide, users and groups alike; schema
publicis no longer writable by PUBLIC since 15;ALTER DEFAULT PRIVILEGESper creating role; predefined roles such aspg_read_all_data(14+) read every table in every database, too broad for "one schema"; a non-superuser'sCREATEROLEgives onlyADMINon the roles it makes since 16 (createrole_self_grant); RLS since 9.5; views check their owner's privileges unlesssecurity_invoker(15+). - DuckDB: no users, roles or
GRANT: whoever can open the database file can read it. Access is the file's permissions, or opening it read only (ATTACH 'lab.duckdb' (READ_ONLY),duckdb -readonly); schemas exist and group tables, without privileges. - SQL Server: a login belongs to the server and a user to one database; fixed database roles such as
db_datareader;GRANT SELECT ON SCHEMA::reports TO reportingcovers tables created in the schema later, so there is nothing like default privileges to set;DENYoverrides any grant; row-level security is a security policy with a predicate function (2016+).