Files
cgalo5758 56e65b669c Read invoices back from Stripe and archive the change
Stripe delivers webhook events in the endpoint's API version. Under a
version later than the 2025-02-24.acacia stripe-go v81 pins, the
payload-shaped invoice handlers projected every invoice with no
subscription link and no lines, and every payment with no payment
intent or method.

- Invoice events read only the invoice id and run ReconcileInvoice,
  which reads the invoice, its lines and its payment intent's charges
  back at the pinned version and converges the core rows under a
  per-invoice lock, in any event order. A payment row is one attempt,
  keyed by its Stripe charge (Stripe store migration 00005).
- Two clocks: created_at is when the console wrote a row; invoices gain
  issued_at and payments paid_at (core migration 00022, backfilled),
  and every invoice and payment list, the activity feed and the
  composite's latest invoice read those.
- An hourly and at-boot invoice sweep reads back invoices never read,
  open invoices once a day, and invoices of dead-lettered events; the
  provider page states when it last finished. The first run is the
  repair of invoices projected so far.
- The API version skew is logged once per version, and
  payment_method.attached links payments recorded before it.
- A restricted key now needs Invoices read and Charges read.
- scripts/db-reader-role.sql maintains console_reader, a login role
  that reads the console's tables without personal data, documented in
  docs/database-management.md and checked against a golden column list
  by TestConsoleReaderColumns. The demo snapshot keeps owners and
  privileges so the role's checks hold on it.
- The demo seed and test fixtures carry issue and paid times.
  status/design-feedback.md gains four upstream notes, one written when
  #60 was closed.

OpenSpec change stripe-invoice-reconcile is archived and its deltas
synced into openspec/specs/. Follow-ups are #150, #151 and #152.
2026-09-28 18:42:29 -05:00

310 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$',
'^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
$$;