Files
member-console/scripts/db-reader-role.sql
T
cgalo5758 7382877ed9 Record a tier delete as an act, inside one transaction on the runner
A plan-wide change to the catalog left no record of itself, so nothing
could say which change moved an organization or offer to run it again
(#187). Migration 00027 adds that record: core.acts, one row per act
with its cause, actor, note and request id, and core.plan_act_details,
the act's plan-specific detail. Both tables are enclosed: only
core.record_plan_act writes them, under the exclusive rendezvous for a
catalog cause, and core_writer can only read them. core_reader reads
every column but the note, which scripts/db-reader-role.sql lists as
personal.

runPlanAct is the one shape the plan acts will share: begin, find the
pools, lock them in ascending id, read again under the locks, act and
commit, with every failure named by its step and a refusal returned as
it is. The tier delete is the first act on it. Inside its exclusive
transaction it counts the tier's live holders (active and suspended),
reads the ladder's tiers and the org types defaulting to it, applies the
last-tier guard, records the act and deletes and renumbers; before this,
the count and the guard's read ran outside the transaction, and a
failed read of the org types let the delete through. A delete of a
product that is no longer a tier of the ladder, a repeated confirm,
writes nothing. The handler only parses, calls and renders:

- A lock wait or a deadlock at any step answers "Another change
  collided with this one; try again." (#216).
- Every other failure answers "Failed to remove tier. Reference:
  {request id}." and logs the step through the request's logger.
- A tier held only by suspended positions is a live holder: the tier
  page routes it to Preview removal, and the plain delete refuses it.

The plan-act golden harness builds the act's queriers through the new
PlanActs seam, so a scenario can fail a statement inside the act's own
transaction, and every golden gains the act and detail sections. Most
gain them empty. Behaviour changes in delete-begin-fails (the log line
names the step and carries the request id; the answer carries the
reference), delete-bottom-tier, delete-top-tier and
tier-change-during-conferral/a-tier-delete (the act row),
delete-held-tier and delete-last-tier-of-a-default (the reads now run
inside the transaction; the answers are unchanged) and contention (the
delete answers the collision sentence; later placeholders renumber
because the delete's log line now carries a request id). New:
delete-org-types-unread and delete-suspended-holder.

The design's failure table for the tier delete now gives the reference
answer for a failed holder count and a failed tier read, as the
approved copy for the delete states, and a row for the repeated delete.
The full suite passes.
2026-10-19 13:35:57 -04:00

311 lines
15 KiB
SQL

-- 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 <database> -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_<schema>; 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$',
'^core\.acts\.note$', -- an act's note is its person's own words
'^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_<schema> 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 <role> 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 <owner>, 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 <table> 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 <table> 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
$$;