-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial -- SPDX-FileCopyrightText: 2025-2026 Christian Galo -- console_reader: a login role for looking into a live deployment's -- database. It reads ids, statuses, amounts, times, catalog text, error -- messages and public DNS names in the console's schemas, and never reads -- the columns in the `personal` list below: names, email addresses, -- identity-provider subjects, IP addresses, card details, operators' notes -- on grants, changes and name rules, workspace descriptions, and raw -- provider payloads. It is not anonymous: an id still links one person's -- payments and times, and a site domain that spells a member's name leads -- to that member's organization. -- -- Run it as the role that owns the console's schemas, which also needs -- CREATEROLE, and again after every deploy that applies a migration: -- -- psql -d -v ON_ERROR_STOP=1 -f scripts/db-reader-role.sql -- -- The run is a single statement, so a refusal or an error leaves the role -- as the previous run left it, whatever the client does after an error. -- The console's schemas are the ones with a migration ledger, a table named -- goose_db_version_; the role reads nothing outside them. -- -- What the role holds: -- - SELECT, granted one column at a time, never on a whole table. A table or -- column that a later migration adds stays unreadable until the next run, -- and that run applies the `personal` list below to it. A new column that -- holds personal data, or an existing column that code starts filling -- with it, must be added to the list before that run. -- - USAGE on the console's schemas. Each run first revokes every privilege -- the role holds in the database, so a column added to the list stops -- being readable on the next run. Like every role, it keeps what PUBLIC -- holds, which includes TEMP on the database. -- - Session defaults: transactions start read-only, a statement stops after -- 15 seconds, and a session left idle inside a transaction is closed -- after a minute. An open transaction keeps its locks, so the next -- migration's ALTER TABLE would wait behind it, and every query after that -- ALTER would wait too. A session can change all three with SET; the -- grants, not these defaults, keep the role from writing. -- -- The run refuses when the role could act beyond its grants: when it has -- SUPERUSER, CREATEROLE, CREATEDB, REPLICATION or BYPASSRLS; when it is a -- member of any role, whose privileges SET ROLE would reach; when it owns -- anything; when it could execute a SECURITY DEFINER function, which runs -- with its owner's rights; or when a privilege survives the revoke, such as -- one another role granted that the runner cannot revoke, or default -- privileges naming the role. The migrations revoke EXECUTE from PUBLIC on -- every SECURITY DEFINER function; a database restored with -- `pg_dump --no-privileges` has lost that revoke. After granting, the run -- fails if the role can read a withheld column or write to a table, as it -- could after a GRANT to PUBLIC. -- -- A view reads its tables with its owner's rights, so a view that reads a -- withheld column, in its select list or only in a WHERE clause, would show -- it to the role. Postgres records which columns a view reads, not which of -- the view's own columns each one feeds, so the run withholds such a view -- whole, along with a view that reads a whole row of a table with a -- withheld column and every view that reads a withheld view, and names -- them. -- -- A table with a withheld column refuses `SELECT *`; name the columns. -- Partitions get no grant of their own: the role reads them through their -- parent table, and the `personal` list matches a partition's columns under -- the parent's name. -- -- The role has no password. The official Postgres image keeps initdb's -- default of trusting the Unix socket and the loopback address, so -- `psql -U console_reader` works from inside the database container, and -- every other client is asked for a password the role does not have. -- POSTGRES_HOST_AUTH_METHOD=trust admits every role without a password, the -- superuser included, to anyone who can reach the port; a password does not -- help there, and scram-sha-256 in pg_hba.conf does. Distribution packages -- authenticate the socket by peer, which needs an operating system user -- named console_reader or a pg_ident.conf map. Give the role a password -- before allowing it over TCP. DO $$ DECLARE -- Columns the role never reads, as regular expressions over -- schema.table.column. Public DNS names and records (site domains, -- claimed domains, a claim's verification token) stay readable; so do -- ids. internal/migrate/testdata/console-reader-columns.txt records what -- the list withholds, and TestConsoleReaderColumns checks it. personal text[] := ARRAY[ '\.payload$', -- raw provider events and outbound commands '^core\.persons\.(display_name|primary_email)$', '^core\.users\.(oidc_subject|last_login_ip)$', '^core\.organizations\.name$', -- a personal organization carries its person's name '^core\.workspaces\.(name|description)$', '^core\.accounts\.(name|metadata)$', '^core\.payment_methods\.card_(last4|exp_month|exp_year)$', '^core\.entitlement_set_change_effects\.org_name$', '^core\.grants\.(description|revocation_reason|metadata)$', -- operator notes can name people '^core\.pool_provision_transitions\.reason$', -- copies an issued grant's description '^core\.(instance_settings|integration_config_overrides)\.updated_by$', -- the signed-in operator's email '^core\.entitlement_set_changes\.note$', '^domains\.name_rules\.note$', '^discourse\.user_links\.discourse_username$' ]; db oid := (SELECT oid FROM pg_database WHERE datname = current_database()); schemas text[]; s text; problem text; whole_views oid[]; obj record; att record; readable text[]; open_columns text[] := '{}'; writable text[] := '{}'; withheld_views text[] := '{}'; granted int := 0; withheld int := 0; BEGIN SELECT array_agg(n.nspname ORDER BY n.nspname) INTO schemas FROM pg_namespace n WHERE EXISTS (SELECT 1 FROM pg_class l WHERE l.relkind = 'r' AND l.relname = 'goose_db_version_' || n.nspname); IF schemas IS NULL THEN RAISE EXCEPTION 'no schema in database % has a goose_db_version_ ledger', current_database() USING HINT = 'Run the script against the console''s database after its migrations.'; END IF; IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'console_reader') THEN CREATE ROLE console_reader LOGIN NOINHERIT CONNECTION LIMIT 3; END IF; SELECT concat_ws(', ', CASE WHEN rolsuper THEN 'SUPERUSER' END, CASE WHEN rolcreaterole THEN 'CREATEROLE' END, CASE WHEN rolcreatedb THEN 'CREATEDB' END, CASE WHEN rolreplication THEN 'REPLICATION' END, CASE WHEN rolbypassrls THEN 'BYPASSRLS' END) INTO problem FROM pg_roles WHERE rolname = 'console_reader'; IF problem <> '' THEN RAISE EXCEPTION 'console_reader has %', problem USING HINT = 'ALTER ROLE console_reader NOSUPERUSER NOCREATEROLE NOCREATEDB NOREPLICATION NOBYPASSRLS'; END IF; SELECT string_agg(m.roleid::regrole::text, ', ' ORDER BY m.roleid::regrole::text) INTO problem FROM pg_auth_members m WHERE m.member = 'console_reader'::regrole; IF problem IS NOT NULL THEN RAISE EXCEPTION 'console_reader is a member of %', problem USING HINT = 'REVOKE FROM console_reader'; END IF; -- pg_shdepend lists, for every database, what a role owns (deptype o) -- and every object whose privileges name it (deptype a). SELECT string_agg(DISTINCT CASE WHEN d.dbid IN (0, db) THEN pg_describe_object(d.classid, d.objid, 0) ELSE 'objects in database ' || quote_ident(o.datname) END, ', ') INTO problem FROM pg_shdepend d LEFT JOIN pg_database o ON o.oid = d.dbid WHERE d.refclassid = 'pg_authid'::regclass AND d.refobjid = 'console_reader'::regrole AND d.deptype = 'o'; IF problem IS NOT NULL THEN RAISE EXCEPTION 'console_reader owns %', problem USING HINT = 'REASSIGN OWNED BY console_reader TO , in each database named'; END IF; SELECT string_agg(p.oid::regprocedure::text, ', ') INTO problem FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE p.prosecdef AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND has_function_privilege('console_reader', p.oid, 'EXECUTE'); IF problem IS NOT NULL THEN RAISE EXCEPTION 'console_reader could execute SECURITY DEFINER functions: %', problem USING HINT = 'REVOKE EXECUTE ON FUNCTION ... FROM PUBLIC, as the migrations do.'; END IF; ALTER ROLE console_reader NOINHERIT; ALTER ROLE console_reader SET default_transaction_read_only = on; ALTER ROLE console_reader SET statement_timeout = '15s'; ALTER ROLE console_reader SET idle_in_transaction_session_timeout = '1min'; -- Start from nothing: revoke every privilege the role holds in this -- database. Revoking a table privilege also revokes the column -- privileges under it. FOR obj IN SELECT DISTINCT d.classid, d.objid FROM pg_shdepend d WHERE d.refclassid = 'pg_authid'::regclass AND d.refobjid = 'console_reader'::regrole AND d.deptype = 'a' AND (d.dbid = db OR (d.classid = 'pg_database'::regclass AND d.objid = db)) LOOP CASE obj.classid WHEN 'pg_class'::regclass THEN EXECUTE format('REVOKE ALL ON TABLE %s FROM console_reader', obj.objid::regclass); WHEN 'pg_namespace'::regclass THEN EXECUTE format('REVOKE ALL ON SCHEMA %s FROM console_reader', obj.objid::regnamespace); WHEN 'pg_proc'::regclass THEN EXECUTE format('REVOKE ALL ON ROUTINE %s FROM console_reader', obj.objid::regprocedure); WHEN 'pg_type'::regclass THEN EXECUTE format('REVOKE ALL ON TYPE %s FROM console_reader', obj.objid::regtype); WHEN 'pg_database'::regclass THEN EXECUTE format('REVOKE ALL ON DATABASE %I FROM console_reader', current_database()); ELSE NULL; -- the check below names it END CASE; END LOOP; SELECT string_agg(DISTINCT pg_describe_object(d.classid, d.objid, 0), ', ') INTO problem FROM pg_shdepend d WHERE d.refclassid = 'pg_authid'::regclass AND d.refobjid = 'console_reader'::regrole AND d.deptype = 'a' AND d.dbid IN (0, db); IF problem IS NOT NULL THEN RAISE EXCEPTION 'console_reader still holds privileges on %', problem USING HINT = 'Revoke them as their grantor or as a superuser.'; END IF; -- Views to withhold whole: those that read a withheld column, read a -- whole row of a table with one, or read a view withheld whole. WITH RECURSIVE reads AS ( SELECT rw.ev_class AS viewid, d.refobjid AS relid, d.refobjsubid AS attnum FROM pg_depend d JOIN pg_rewrite rw ON rw.oid = d.objid WHERE d.classid = 'pg_rewrite'::regclass AND d.refclassid = 'pg_class'::regclass AND d.refobjid <> rw.ev_class ), personal_columns AS ( SELECT c.oid AS relid, pa.attnum FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_class rc ON rc.oid = coalesce(pg_partition_root(c.oid), c.oid) JOIN pg_namespace rn ON rn.oid = rc.relnamespace JOIN pg_attribute pa ON pa.attrelid = c.oid AND pa.attnum > 0 AND NOT pa.attisdropped WHERE n.nspname = ANY (schemas) AND (rn.nspname || '.' || rc.relname || '.' || pa.attname) ~ ANY (personal) ), tainted(viewid) AS ( SELECT v.viewid FROM reads v JOIN personal_columns p ON p.relid = v.relid AND v.attnum IN (0, p.attnum) UNION SELECT v.viewid FROM reads v JOIN tainted t ON t.viewid = v.relid ) SELECT coalesce(array_agg(viewid), '{}') INTO whole_views FROM tainted; FOR obj IN SELECT c.oid, format('%I.%I', n.nspname, c.relname) AS rel, c.relispartition AS is_partition, rn.nspname || '.' || rc.relname AS name, -- a partition's parent c.oid = ANY (whole_views) AS whole FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace JOIN pg_class rc ON rc.oid = coalesce(pg_partition_root(c.oid), c.oid) JOIN pg_namespace rn ON rn.oid = rc.relnamespace WHERE n.nspname = ANY (schemas) AND c.relkind IN ('r', 'p', 'v', 'm', 'f') ORDER BY n.nspname, c.relname LOOP readable := '{}'; FOR att IN SELECT attname::text AS col FROM pg_attribute WHERE attrelid = obj.oid AND attnum > 0 AND NOT attisdropped ORDER BY attnum LOOP IF obj.whole OR (obj.name || '.' || att.col) ~ ANY (personal) THEN IF NOT obj.is_partition THEN withheld := withheld + 1; END IF; IF has_column_privilege('console_reader', obj.oid, att.col, 'SELECT') THEN open_columns := array_append(open_columns, obj.rel || '.' || att.col); END IF; ELSIF NOT obj.is_partition THEN readable := array_append(readable, att.col); END IF; END LOOP; IF cardinality(readable) > 0 THEN EXECUTE format('GRANT SELECT (%s) ON %s TO console_reader', (SELECT string_agg(quote_ident(c), ', ') FROM unnest(readable) c), obj.rel); granted := granted + (SELECT count(*) FROM unnest(readable) c WHERE has_column_privilege('console_reader', obj.oid, c, 'SELECT')); END IF; IF obj.whole THEN withheld_views := array_append(withheld_views, obj.rel); END IF; IF has_any_column_privilege('console_reader', obj.oid, 'INSERT, UPDATE') OR has_table_privilege('console_reader', obj.oid, 'DELETE, TRUNCATE, TRIGGER') THEN writable := array_append(writable, obj.rel); END IF; END LOOP; FOREACH s IN ARRAY schemas LOOP EXECUTE format('GRANT USAGE ON SCHEMA %I TO console_reader', s); END LOOP; -- The revoke above reaches only grants made to console_reader itself; a -- grant to PUBLIC survives it. IF cardinality(open_columns) > 0 THEN RAISE EXCEPTION 'console_reader can read withheld columns: %', array_to_string(open_columns, ', ') USING HINT = 'REVOKE SELECT ON FROM PUBLIC'; END IF; IF cardinality(writable) > 0 THEN RAISE EXCEPTION 'console_reader can write to %', array_to_string(writable, ', ') USING HINT = 'REVOKE INSERT, UPDATE, DELETE, TRUNCATE, TRIGGER ON
FROM PUBLIC'; END IF; RAISE NOTICE 'console_reader: % columns readable, % withheld, schemas %', granted, withheld, schemas; IF cardinality(withheld_views) > 0 THEN RAISE NOTICE 'console_reader: views withheld whole: %', array_to_string(withheld_views, ', '); END IF; END $$;