fix(rls): block member self-change of payment/dues columns (D9)

prevent_member_self_role_change covered role/status/space but not the
financial columns; RLS members_update lets a member UPDATE their own
row, so payment_status/dues_paid_until/last_paid_at/last_payment_at/
stripe_customer_id/payment_note/joined_at were self-settable via
PostgREST to forge a dues-good signal. Migration 044 adds them to the
blocked set; service-client (auth.uid() NULL) + privileged-on-another-
member writes still bypass (Stripe webhook unaffected). Schema + maps.
This commit is contained in:
Moheeb Zara
2026-05-18 18:41:35 -07:00
parent 26e54f1fcb
commit 785e8e0ef9
4 changed files with 70 additions and 5 deletions
+1
View File
@@ -409,6 +409,7 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| 041 | Notifications P2: `notifications` outbox (type, recipient, subject, body_html/text, status, attempts, dedupe_key). UNIQUE(space_id,dedupe_key) = idempotent webhook enqueue; partial idx (created_at) WHERE status='pending'. SELECT admin/board/treasurer, service-client-only writes (webhook enqueue + dispatcher cron); member self-view via validated action | | 041 | Notifications P2: `notifications` outbox (type, recipient, subject, body_html/text, status, attempts, dedupe_key). UNIQUE(space_id,dedupe_key) = idempotent webhook enqueue; partial idx (created_at) WHERE status='pending'. SELECT admin/board/treasurer, service-client-only writes (webhook enqueue + dispatcher cron); member self-view via validated action |
| 042 | Equipment double-booking P0 fix: `btree_gist` extension + `equipment_reservations_no_overlap` GiST EXCLUDE (equipment_id =, tstzrange(starts_at,ends_at,'[)') &&) WHERE status='reserved'. DB is the concurrency arbiter; reserveEquipment maps 23P01 to a friendly message. No new table/column | | 042 | Equipment double-booking P0 fix: `btree_gist` extension + `equipment_reservations_no_overlap` GiST EXCLUDE (equipment_id =, tstzrange(starts_at,ends_at,'[)') &&) WHERE status='reserved'. DB is the concurrency arbiter; reserveEquipment maps 23P01 to a friendly message. No new table/column |
| 043 | `members_update` RLS gains a WITH CHECK mirroring its USING (defense-in-depth: a privileged caller can't mutate a member row into a space they don't administer). No new table/column | | 043 | `members_update` RLS gains a WITH CHECK mirroring its USING (defense-in-depth: a privileged caller can't mutate a member row into a space they don't administer). No new table/column |
| 044 | `prevent_member_self_role_change` also blocks self-change of payment_status, payment_note, dues_paid_until, last_paid_at, last_payment_at, stripe_customer_id, joined_at (member could otherwise forge a dues-good signal via PostgREST). Service-client/privileged writes still bypass. No new table/column |
--- ---
+1
View File
@@ -724,6 +724,7 @@ migration is mirrored as a section in it. Tables/columns added:
| 041 | Transactional notifications P2. `notifications` outbox (space_id, member_id, type, recipient, subject, body_html/text, status pending/sent/failed, attempts, dedupe_key). `UNIQUE (space_id, dedupe_key)` makes the Stripe-webhook enqueue idempotent (event replay -> no-op). SELECT = admin/board/treasurer, NO client write policy (webhook enqueue + dispatcher cron, service-client only); member self-view via a validated action. Partial index on `(created_at) WHERE status='pending'` for the dispatcher drain. No new permission code | | 041 | Transactional notifications P2. `notifications` outbox (space_id, member_id, type, recipient, subject, body_html/text, status pending/sent/failed, attempts, dedupe_key). `UNIQUE (space_id, dedupe_key)` makes the Stripe-webhook enqueue idempotent (event replay -> no-op). SELECT = admin/board/treasurer, NO client write policy (webhook enqueue + dispatcher cron, service-client only); member self-view via a validated action. Partial index on `(created_at) WHERE status='pending'` for the dispatcher drain. No new permission code |
| 042 | Equipment double-booking P0 fix. `CREATE EXTENSION btree_gist` + `equipment_reservations_no_overlap` `EXCLUDE USING gist (equipment_id WITH =, tstzrange(starts_at,ends_at,'[)') WITH &&) WHERE status='reserved'`. Makes the DB the concurrency arbiter (the app-side check-then-insert was a TOCTOU); `reserveEquipment` maps the 23P01 exclusion violation to the friendly overlap message. No new table/column/policy/permission | | 042 | Equipment double-booking P0 fix. `CREATE EXTENSION btree_gist` + `equipment_reservations_no_overlap` `EXCLUDE USING gist (equipment_id WITH =, tstzrange(starts_at,ends_at,'[)') WITH &&) WHERE status='reserved'`. Makes the DB the concurrency arbiter (the app-side check-then-insert was a TOCTOU); `reserveEquipment` maps the 23P01 exclusion violation to the friendly overlap message. No new table/column/policy/permission |
| 043 | `members_update` RLS policy gains a `WITH CHECK` mirroring its `USING` (`user_id = auth.uid()` OR admin/board/treasurer in `space_id`). Defense-in-depth: the post-image is re-validated so a privileged caller cannot move a member row into a space they do not administer. No new table/column | | 043 | `members_update` RLS policy gains a `WITH CHECK` mirroring its `USING` (`user_id = auth.uid()` OR admin/board/treasurer in `space_id`). Defense-in-depth: the post-image is re-validated so a privileged caller cannot move a member row into a space they do not administer. No new table/column |
| 044 | `prevent_member_self_role_change` extended to also block a non-privileged member changing their own `payment_status`, `payment_note`, `dues_paid_until`, `last_paid_at`, `last_payment_at`, `stripe_customer_id`, `joined_at` (RLS lets a member UPDATE their own row, so these were self-settable to forge a dues-good signal). Service-client (`auth.uid()` NULL) and privileged-on-another-member writes still bypass. No new table/column |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
@@ -0,0 +1,52 @@
-- =============================================================================
-- 044: Block member self-change of financial columns
-- =============================================================================
-- prevent_member_self_role_change protected role/tier/status/approved/
-- card-access/onboarding/space but NOT the financial columns. RLS
-- members_update lets a member UPDATE their own row, so a member could
-- self-set payment_status / dues_paid_until / last_paid_at / etc. via
-- PostgREST and forge a "dues good" signal. Add them to the blocked set.
-- Legitimate writers are the Stripe webhook / treasurer actions via the
-- service client (auth.uid() IS NULL -> early return, unaffected) or a
-- privileged user editing ANOTHER member (NEW.user_id <> auth.uid()).
--
-- Idempotent: CREATE OR REPLACE FUNCTION.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.prevent_member_self_role_change()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
DECLARE
v_is_privileged boolean;
BEGIN
IF auth.uid() IS NULL THEN RETURN NEW; END IF;
IF NEW.user_id IS DISTINCT FROM auth.uid() THEN RETURN NEW; END IF;
SELECT public.user_has_role_in_space(auth.uid(), NEW.space_id, ARRAY['admin','board','treasurer'])
INTO v_is_privileged;
IF v_is_privileged THEN RETURN NEW; END IF;
IF NEW.role IS DISTINCT FROM OLD.role
OR NEW.tier IS DISTINCT FROM OLD.tier
OR NEW.tier_id IS DISTINCT FROM OLD.tier_id
OR NEW.status IS DISTINCT FROM OLD.status
OR NEW.approved IS DISTINCT FROM OLD.approved
OR NEW.has_card_access IS DISTINCT FROM OLD.has_card_access
OR NEW.onboarding_completed_at IS DISTINCT FROM OLD.onboarding_completed_at
OR NEW.space_id IS DISTINCT FROM OLD.space_id
OR NEW.payment_status IS DISTINCT FROM OLD.payment_status
OR NEW.payment_note IS DISTINCT FROM OLD.payment_note
OR NEW.dues_paid_until IS DISTINCT FROM OLD.dues_paid_until
OR NEW.last_paid_at IS DISTINCT FROM OLD.last_paid_at
OR NEW.last_payment_at IS DISTINCT FROM OLD.last_payment_at
OR NEW.stripe_customer_id IS DISTINCT FROM OLD.stripe_customer_id
OR NEW.joined_at IS DISTINCT FROM OLD.joined_at THEN
RAISE EXCEPTION 'Members cannot change their own role, tier, status, approval, card access, onboarding completion, space, or payment/dues fields.'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_prevent_member_self_role_change ON public.space_members;
CREATE TRIGGER trg_prevent_member_self_role_change
BEFORE UPDATE ON public.space_members
FOR EACH ROW EXECUTE FUNCTION public.prevent_member_self_role_change();
+16 -5
View File
@@ -1856,9 +1856,13 @@ CREATE TRIGGER trg_seed_default_role_permissions
-- ============================================================================= -- =============================================================================
-- 16. Self-change hardening (equivalent to scripts/024_self_change_hardening.sql). -- 16. Self-change hardening (equivalent to scripts/024 + scripts/044).
-- Adds tier_id and onboarding_completed_at to the columns a non-privileged -- Columns a non-privileged member cannot change on their own
-- member cannot change on their own space_members row. -- space_members row: role, tier(_id), status, approved, has_card_access,
-- onboarding_completed_at, space_id, and the payment/dues fields
-- (payment_status, payment_note, dues_paid_until, last_paid_at,
-- last_payment_at, stripe_customer_id, joined_at). Service-client and
-- privileged-on-another-member writes bypass (auth.uid() guards).
-- ============================================================================= -- =============================================================================
CREATE OR REPLACE FUNCTION public.prevent_member_self_role_change() CREATE OR REPLACE FUNCTION public.prevent_member_self_role_change()
@@ -1879,8 +1883,15 @@ BEGIN
OR NEW.approved IS DISTINCT FROM OLD.approved OR NEW.approved IS DISTINCT FROM OLD.approved
OR NEW.has_card_access IS DISTINCT FROM OLD.has_card_access OR NEW.has_card_access IS DISTINCT FROM OLD.has_card_access
OR NEW.onboarding_completed_at IS DISTINCT FROM OLD.onboarding_completed_at OR NEW.onboarding_completed_at IS DISTINCT FROM OLD.onboarding_completed_at
OR NEW.space_id IS DISTINCT FROM OLD.space_id THEN OR NEW.space_id IS DISTINCT FROM OLD.space_id
RAISE EXCEPTION 'Members cannot change their own role, tier, status, approval, card access, onboarding completion, or space.' OR NEW.payment_status IS DISTINCT FROM OLD.payment_status
OR NEW.payment_note IS DISTINCT FROM OLD.payment_note
OR NEW.dues_paid_until IS DISTINCT FROM OLD.dues_paid_until
OR NEW.last_paid_at IS DISTINCT FROM OLD.last_paid_at
OR NEW.last_payment_at IS DISTINCT FROM OLD.last_payment_at
OR NEW.stripe_customer_id IS DISTINCT FROM OLD.stripe_customer_id
OR NEW.joined_at IS DISTINCT FROM OLD.joined_at THEN
RAISE EXCEPTION 'Members cannot change their own role, tier, status, approval, card access, onboarding completion, space, or payment/dues fields.'
USING ERRCODE = '42501'; USING ERRCODE = '42501';
END IF; END IF;
RETURN NEW; RETURN NEW;