The Discourse receiver stored each verified event body whole in core.webhook_events.payload, so every forum signup's email address, username, name, avatar, profile fields and external id stayed in the database and its backups, members and non-members alike (#163). Nothing reads a stored Discourse payload back: the receiver handles the event from the body it was delivered. The receiver now parses the body once, after it takes the dedupe key, and stores only the ids processing reads: {"user":{"id":...}} for user_created, {"group_user":{"group_id":...,"user_id":...}} for the two group events. A missing id is stored as 0. A body that does not parse, or lacks the object its event names, is stored as {} and recorded failed. Email mode still reads the address from the delivered body. Two answers change. A signed body that is not JSON, or is empty, failed the JSONB insert: 500, no row, and redelivery of the same bytes, which can only fail again. It now gets a failed row and 200, as every recorded event does. A body Go parses but JSONB rejects (a \u0000 escape, invalid UTF-8) answered 500 on every delivery; its ids are now stored and it is processed. docs/models/payments-billing.md says what each receiver stores, and the comments of scripts/db-reader-role.sql stop calling the column raw provider payloads; no reader column changes. The OpenSpec change discourse-events-keep-forum-ids is added with this commit. Ride-along: the test helper newMapping drops a return value nothing read. Deliberate breaks, scripts/mutation-check -side new, 29 records, all CAUGHT: the insert stores the raw body (8 rows of both tests) keep copies the email into the stored user keep drops UserID from the stored group user (both group rows) keep swaps GroupID and UserID (both group rows) keep takes the user object for every event type (3 rows) keep stores a user with id 0 when the user object is nil a parse error stores keep of the partly filled view the parse-error path answers 500 (the three parse-error rows) resolvePerson is called with an empty email an event of another type is not dropped a group event without group_user reconciles from the user object the insert writes the body into provider_environment the processed body is the stored one (NUL escape, invalid UTF-8) the stored group user omits a zero group_id, and a zero user_id
312 lines
15 KiB
SQL
312 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 the
|
|
-- provider event data that receivers keep and the commands in core.outbox.
|
|
-- 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$', -- provider event data a receiver keeps, 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
|
|
$$;
|