fix(rls): members_update WITH CHECK mirrors USING (D8)

UPDATE policy was USING-only: a privileged caller could mutate a member
row into a space they don't administer (no post-image re-check).
Migration 043 adds the mirrored WITH CHECK. Self column escalation
(space_id/role/status) remains blocked by the trigger (D9). Schema +
DB maps updated.
This commit is contained in:
Moheeb Zara
2026-05-18 18:39:53 -07:00
parent 7b0948ddd6
commit 26e54f1fcb
4 changed files with 29 additions and 0 deletions
+1
View File
@@ -408,6 +408,7 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| 040 | Stripe dues P1: `payment_platform` += `stripe`; `member_billing` (member↔Stripe customer/sub/status; SELECT admin/board/treasurer, service-client-only writes); `stripe_webhook_events` (idempotency, PK=event id, service-only). Per-space own keys (vault + integrations.config) | | 040 | Stripe dues P1: `payment_platform` += `stripe`; `member_billing` (member↔Stripe customer/sub/status; SELECT admin/board/treasurer, service-client-only writes); `stripe_webhook_events` (idempotency, PK=event id, service-only). Per-space own keys (vault + integrations.config) |
| 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 |
--- ---
+1
View File
@@ -723,6 +723,7 @@ migration is mirrored as a section in it. Tables/columns added:
| 040 | Stripe recurring dues P1. `payment_platform` += `'stripe'` (ALTER TYPE ADD VALUE; mirrored in the CREATE TYPE list). `member_billing` (member↔Stripe customer/subscription/status/period; SELECT = admin/board/treasurer, NO client write policy — webhook/service-client only). `stripe_webhook_events` (idempotency ledger, PK = Stripe event id; service-client only, no policy). Per-space OWN keys: secrets in the vault, config in integrations.config. No new permission code | | 040 | Stripe recurring dues P1. `payment_platform` += `'stripe'` (ALTER TYPE ADD VALUE; mirrored in the CREATE TYPE list). `member_billing` (member↔Stripe customer/subscription/status/period; SELECT = admin/board/treasurer, NO client write policy — webhook/service-client only). `stripe_webhook_events` (idempotency ledger, PK = Stripe event id; service-client only, no policy). Per-space OWN keys: secrets in the vault, config in integrations.config. 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 | | 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 |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
+23
View File
@@ -0,0 +1,23 @@
-- =============================================================================
-- 043: space_members UPDATE — add WITH CHECK (defense-in-depth)
-- =============================================================================
-- members_update had only a USING clause: a privileged caller could mutate a
-- member row into a space where they hold no role (cross-space move) because
-- the post-image was never re-checked. Add a WITH CHECK mirroring the USING
-- so the resulting row must still satisfy the same predicate. (Self column
-- escalation -- space_id/role/status -- is separately blocked by the
-- prevent_member_self_role_change trigger; see scripts/044.)
--
-- Idempotent: DROP POLICY IF EXISTS then CREATE.
-- =============================================================================
DROP POLICY IF EXISTS members_update ON public.space_members;
CREATE POLICY members_update ON public.space_members FOR UPDATE
USING (
user_id = auth.uid()
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])
)
WITH CHECK (
user_id = auth.uid()
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])
);
+4
View File
@@ -477,6 +477,10 @@ CREATE POLICY members_update ON public.space_members FOR UPDATE
USING ( USING (
user_id = auth.uid() user_id = auth.uid()
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']) OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])
)
WITH CHECK (
user_id = auth.uid()
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])
); );
CREATE POLICY members_delete ON public.space_members FOR DELETE CREATE POLICY members_delete ON public.space_members FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])); USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));