Two findings from an ultrathink audit of the Phase 4 work. (1) members_with_permission was callable by any PostgREST client. Postgres grants EXECUTE on new functions to PUBLIC by default, and this codebase has no existing REVOKE pattern. The function returns a SET of member_ids for any (space, permission) pair, so without a lockdown ANY authenticated caller could enumerate "who has permission X in space Y" for spaces they are not even members of, learning member_ids and the permission distribution. user_has_permission has a narrower exposure (per-uid boolean) and is needed by anon/authenticated for RLS-policy evaluation, so it stays callable; the new function is locked to service_role since the only legitimate caller (the form-submission fan-out) goes through the admin client. Migration 047 + schema.sql now REVOKE EXECUTE FROM PUBLIC and GRANT TO service_role. (2) The Phase 4 enqueue blocks in equipment.ts (reserve, cancel) and classes.ts (signUp, cancelMySignup waitlist promotion, updateSession session-cancellation fan-out) used the shared helpers, but the helpers themselves were not internally best-effort. The Stripe webhook's original enqueueDues wrapped its entire body in try/catch so a thrown member-lookup error could never bubble into the money path; my refactor removed that inner wrap and relied on enqueueNotification's best-effort. resolveMemberContact and getSpaceName could still throw on a transient supabase network error, which would surface to the action return value after the underlying mutation already committed: the client would see an error even though their booking, signup, or session-cancellation succeeded. Fixed at both layers: the helpers now swallow throws internally (returning null / empty), and each call site in equipment/classes is wrapped in try/catch around the lookup chain so secondary action-specific queries (equipment name+location lookup, promoted-signup session lookup, session-cancellation signup fan-out) also cannot leak. forms.submitForm already had the wrap from P4d. 2 new helper tests pin the best-effort behavior; suite 567, build clean.
49 lines
2.6 KiB
PL/PgSQL
49 lines
2.6 KiB
PL/PgSQL
-- =============================================================================
|
|
-- 047: members_with_permission helper (Product spine Phase 4 notifications)
|
|
-- =============================================================================
|
|
-- The dues outbox is member-keyed: one recipient per row. The new form-
|
|
-- submission "admin alert" fans out: one outbox row per recipient member who
|
|
-- holds a given permission (e.g. forms.manage). Today the only available
|
|
-- function is user_has_permission(uid, sid, perm): a per-user check. Iterating
|
|
-- it across every member of a space would be N round-trips for every form
|
|
-- submit -- bad latency, bad scaling.
|
|
--
|
|
-- members_with_permission(sid, perm) is the inverted, set-returning version of
|
|
-- user_has_permission. Same membership-status gate (current/late only, mirrors
|
|
-- migration 046), same admin-shortcut, same space_role_permissions +
|
|
-- user_effective_roles fallback. Additive: it adds nothing to RLS surface,
|
|
-- introduces no new tables/policies, and existing callers continue to use
|
|
-- user_has_permission unchanged.
|
|
--
|
|
-- Idempotent: CREATE OR REPLACE. Apply as-is (Supabase SQL editor / psql);
|
|
-- re-runs are no-ops.
|
|
-- =============================================================================
|
|
|
|
CREATE OR REPLACE FUNCTION public.members_with_permission(sid uuid, perm text)
|
|
RETURNS TABLE(member_id uuid) LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public
|
|
AS $$
|
|
SELECT sm.id
|
|
FROM public.space_members sm
|
|
WHERE sm.space_id = sid
|
|
AND sm.status IN ('current','late') -- privilege-eligible only, mirrors 046
|
|
AND (
|
|
sm.role = 'admin' -- admins always pass (matches user_has_permission)
|
|
OR EXISTS (
|
|
SELECT 1 FROM public.space_role_permissions p
|
|
WHERE p.space_id = sid AND p.permission = perm
|
|
AND p.subject IN (SELECT public.user_effective_roles(sm.user_id, sid))
|
|
)
|
|
);
|
|
$$;
|
|
|
|
-- LOCKDOWN: unlike user_has_permission (a per-user boolean that is hard to
|
|
-- enumerate without already knowing target uids), members_with_permission
|
|
-- returns a SET of member_ids for any (space, permission) pair. By default
|
|
-- Postgres grants EXECUTE on a new function to PUBLIC, so without an explicit
|
|
-- REVOKE this function would let any authenticated PostgREST caller enumerate
|
|
-- "who has permission X in space Y" across spaces they are not even members
|
|
-- of. Lock it to service_role: the notifications fan-out goes through the
|
|
-- admin (service-role) client; no client should call this directly.
|
|
REVOKE EXECUTE ON FUNCTION public.members_with_permission(uuid, text) FROM PUBLIC;
|
|
GRANT EXECUTE ON FUNCTION public.members_with_permission(uuid, text) TO service_role;
|