fix(edge-fn): replace getClaims with adminClient.auth.getUser(token)

fix(edge-fn): use user.id instead of claims.sub; fixes 500s and false cert_required
fix(migrations): drop broad reservations SELECT policy; add reservation_slots view with security_invoker=false
fix(tests): correct weekSlot() keys from start/end to start_time/end_time
fix(tests): spread overlap test slots across separate ISO weeks
fix(tests): update e2e assertion to match actual authenticated home text
fix(app): hide IonMenu before user is authenticated
feat(dx): add test:all script running unit, integration, and e2e in sequence
docs(claude-md): document SELinux fix, Edge Function auth pattern, security_invoker behaviour
This commit is contained in:
2026-04-20 14:32:37 -04:00
parent d07a02c9dc
commit 108c042921
33 changed files with 2745 additions and 12 deletions

View File

@@ -0,0 +1,54 @@
-- Overlap prevention for reservations using btree_gist exclusion constraint
create extension if not exists btree_gist;
alter table public.reservations
add constraint no_overlapping_reservations
exclude using gist (
boat_id with =,
tstzrange(start_time, end_time, '[)') with &&
);
-- Function: check member has required certs for a boat
create or replace function public.member_has_cert_for_boat(p_user_id uuid, p_boat_id uuid)
returns boolean
language sql
stable
security definer
set search_path = public
as $$
select
case
when array_length(b.required_certs, 1) is null or array_length(b.required_certs, 1) = 0
then true
else
coalesce(
(select m.certifications @> b.required_certs
from public.members m
where m.user_id = p_user_id),
false
)
end
from public.boats b
where b.id = p_boat_id;
$$;
-- Trigger: enforce cert check on reservation insert
create or replace function public.enforce_reservation_cert_check()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
if not coalesce(public.member_has_cert_for_boat(new.user_id, new.boat_id), false) then
raise exception 'Member does not have required certifications for this boat';
end if;
return new;
end;
$$;
create trigger check_reservation_certs
before insert on public.reservations
for each row execute function public.enforce_reservation_cert_check();
grant execute on function public.member_has_cert_for_boat(uuid, uuid) to authenticated;

View File

@@ -0,0 +1,104 @@
-- ============================================================
-- BOOKING CONFIG: configurable rule parameters (read by Edge Function)
-- ============================================================
create table public.booking_config (
key text primary key,
value jsonb not null,
description text
);
alter table public.booking_config enable row level security;
create policy "Authenticated users can read booking config" on public.booking_config
for select using (auth.role() = 'authenticated');
create policy "Admins can manage booking config" on public.booking_config
for all using (
exists (
select 1 from public.members
where user_id = auth.uid() and role in ('admin', 'boatswain')
)
);
insert into public.booking_config (key, value, description) values
('max_sessions_per_week', '2', 'Max pre-booked sessions per member per ISO week (MonSun)'),
('max_weekend_sessions_per_period', '1', 'Max weekend/holiday sessions per alternating period'),
('weekend_period_weeks', '2', 'Number of weeks in the alternating weekend period'),
('open_session_advance_hours', '24', 'Hours before session start where pre-booking limits are waived');
-- ============================================================
-- HOLIDAYS: configurable statutory holiday list
-- ============================================================
create table public.holidays (
date date primary key,
name text not null
);
alter table public.holidays enable row level security;
create policy "Authenticated users can read holidays" on public.holidays
for select using (auth.role() = 'authenticated');
create policy "Admins can manage holidays" on public.holidays
for all using (
exists (
select 1 from public.members
where user_id = auth.uid() and role in ('admin', 'boatswain')
)
);
-- Ontario statutory + civic holidays for 2026 sailing season
insert into public.holidays (date, name) values
('2026-05-18', 'Victoria Day'),
('2026-07-01', 'Canada Day'),
('2026-08-03', 'Civic Holiday'),
('2026-09-07', 'Labour Day'),
('2026-10-12', 'Thanksgiving');
-- ============================================================
-- HELPER: is_weekend_or_holiday (used by Edge Function via RPC)
-- ============================================================
create or replace function public.is_weekend_or_holiday(p_date date)
returns boolean
language sql stable security definer
set search_path = public
as $$
select
extract(dow from p_date) in (0, 6)
or exists (select 1 from public.holidays where date = p_date);
$$;
grant execute on function public.is_weekend_or_holiday(date) to authenticated;
-- ============================================================
-- LOCK DIRECT INSERT: revoke INSERT on reservations from authenticated
--
-- Members must create reservations through the create-reservation Edge Function.
-- The Edge Function uses the service_role key (bypasses RLS).
-- Admins retain direct INSERT/UPDATE/DELETE via their existing "all" policy.
-- ============================================================
drop policy if exists "Users can create own reservations" on public.reservations;
-- Admins can still manage directly; members go through the Edge Function.
-- The overlap exclusion constraint and cert check trigger remain as DB-level safety nets.
-- ============================================================
-- RBAC VIEW: reservation_slots
--
-- Exposes only boat_id, start_time, end_time, status to all authenticated users.
-- Hides user_id, reason, comment, member_ids, guest_ids.
-- Members use this view to check slot availability.
-- Admins query the reservations table directly for full management.
-- ============================================================
create view public.reservation_slots
with (security_invoker = false)
as
select id, boat_id, start_time, end_time, status
from public.reservations;
grant select on public.reservation_slots to authenticated;

View File

@@ -0,0 +1,76 @@
-- Fix infinite recursion in members RLS.
--
-- The original "Admins can read all members" and "Admins can manage all members"
-- policies check role by querying members itself, which causes infinite recursion
-- when any authenticated user accesses the members table.
--
-- Solution: SECURITY DEFINER helper functions that bypass RLS to check the caller's
-- role, then reference those functions from the policies.
create or replace function public.current_user_role()
returns text
language sql
security definer
stable
set search_path = public
as $$
select role from public.members where user_id = auth.uid() limit 1;
$$;
create or replace function public.current_user_has_role(roles text[])
returns boolean
language sql
security definer
stable
set search_path = public
as $$
select exists (
select 1 from public.members
where user_id = auth.uid() and role = any(roles)
);
$$;
-- Drop the recursive policies on members
drop policy if exists "Admins can read all members" on public.members;
drop policy if exists "Admins can manage all members" on public.members;
-- Replace with non-recursive equivalents using the SECURITY DEFINER helper
create policy "Admins can read all members" on public.members
for select using (public.current_user_has_role(array['admin', 'boatswain', 'instructor']));
create policy "Admins can manage all members" on public.members
for all using (public.current_user_has_role(array['admin']));
-- Also fix all other tables that query members inline (same recursion risk)
drop policy if exists "Admins can manage boats" on public.boats;
create policy "Admins can manage boats" on public.boats
for all using (public.current_user_has_role(array['admin', 'boatswain']));
drop policy if exists "Admins can manage interval templates" on public.interval_templates;
create policy "Admins can manage interval templates" on public.interval_templates
for all using (public.current_user_has_role(array['admin', 'boatswain']));
drop policy if exists "Admins can manage intervals" on public.intervals;
create policy "Admins can manage intervals" on public.intervals
for all using (public.current_user_has_role(array['admin', 'boatswain']));
drop policy if exists "Admins can read all reservations" on public.reservations;
create policy "Admins can read all reservations" on public.reservations
for select using (public.current_user_has_role(array['admin', 'boatswain']));
drop policy if exists "Admins can manage all reservations" on public.reservations;
create policy "Admins can manage all reservations" on public.reservations
for all using (public.current_user_has_role(array['admin', 'boatswain']));
drop policy if exists "Admins can manage reference docs" on public.reference_docs;
create policy "Admins can manage reference docs" on public.reference_docs
for all using (public.current_user_has_role(array['admin']));
-- booking_config and holidays policies (added in later migration, same pattern)
drop policy if exists "Admins can manage booking config" on public.booking_config;
create policy "Admins can manage booking config" on public.booking_config
for all using (public.current_user_has_role(array['admin']));
drop policy if exists "Admins can manage holidays" on public.holidays;
create policy "Admins can manage holidays" on public.holidays
for all using (public.current_user_has_role(array['admin']));

View File

@@ -0,0 +1,4 @@
-- Drop the overly-broad SELECT policy that allowed any authenticated user to read
-- all reservations. Non-owner visibility is now handled by the reservation_slots
-- view (security_invoker, exposes only id/boat_id/start_time/end_time/status).
drop policy if exists "Authenticated users can read non-private reservation slots" on public.reservations;

View File

@@ -0,0 +1,14 @@
-- The reservation_slots view was created with security_invoker=true, which means
-- it evaluates RLS as the calling user. After removing the broad select policy,
-- other members see 0 rows. Switch to security_definer so the view runs as the
-- owner (bypassing RLS), while still exposing only the safe columns.
drop view if exists public.reservation_slots;
create view public.reservation_slots
with (security_invoker = false)
as
select id, boat_id, start_time, end_time, status
from public.reservations;
grant select on public.reservation_slots to authenticated;