Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions migrations/007_atomic_band_claim.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
-- Migration: 007_atomic_band_claim.sql
-- Description: Replaces the multi-step client-side band claim flow with a single atomic RPC.
-- This eliminates race conditions where two users might claim the same band simultaneously,
-- and prevents partial failures (e.g., band assigned but token not consumed).

CREATE OR REPLACE FUNCTION public.claim_band_atomic(
p_token text,
p_band_id text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_claim record;
v_band record;
v_now timestamptz := now();
BEGIN
-- 1. Lock and validate the token claim
SELECT * INTO v_claim
FROM token_claims
WHERE token_hash = p_token
FOR UPDATE;

IF NOT FOUND THEN
RETURN jsonb_build_object(
'success', false,
'code', 'invalid_token',
'message', 'Claim session not found.'
);
END IF;

IF v_claim.used_at IS NOT NULL THEN
RETURN jsonb_build_object(
'success', false,
'code', 'token_already_used',
'message', 'This registration link has already been used.'
);
END IF;

IF v_claim.expires_at < v_now THEN
RETURN jsonb_build_object(
'success', false,
'code', 'expired_token',
'message', 'This claim link has expired.'
);
END IF;

-- 2. Lock and validate the band
SELECT * INTO v_band
FROM bands
WHERE band_id = p_band_id
FOR UPDATE;

IF NOT FOUND THEN
RETURN jsonb_build_object(
'success', false,
'code', 'band_not_found',
'message', 'That wristband number was not found.'
);
END IF;

IF v_band.status != 'available' THEN
RETURN jsonb_build_object(
'success', false,
'code', 'band_unavailable',
'message', 'This wristband is already assigned.'
);
END IF;

-- 3. Execute the atomic updates
-- Note: We update bands first, then athletes, to satisfy the circular FK if deferred,
-- or just to maintain the same order the client used.

UPDATE bands
SET
status = 'assigned',
athlete_id = v_claim.athlete_id,
assigned_at = v_now
WHERE band_id = p_band_id;

UPDATE athletes
SET band_id = p_band_id
WHERE id = v_claim.athlete_id;

UPDATE token_claims
SET used_at = v_now
WHERE token_hash = p_token;

-- 4. Return success payload
RETURN jsonb_build_object(
'success', true,
'code', 'success',
'athlete_id', v_claim.athlete_id,
'band_id', p_band_id,
'display_number', v_band.display_number
);

EXCEPTION WHEN OTHERS THEN
-- Catch any unexpected DB errors (e.g., constraint violations) and return a safe payload
-- The transaction will automatically roll back.
RETURN jsonb_build_object(
'success', false,
'code', 'claim_failed',
'message', SQLERRM
);
END;
$$;

-- Grant execute permission to authenticated and anon roles
GRANT EXECUTE ON FUNCTION public.claim_band_atomic(text, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.claim_band_atomic(text, text) TO anon;
88 changes: 88 additions & 0 deletions migrations/008_security_and_schema_alignment.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
-- Migration: 008_security_and_schema_alignment.sql
-- Description: Hardens RLS policies across core tables and fixes schema drift

-- ============================================================================
-- 1. SCHEMA DRIFT FIXES
-- ============================================================================

-- Fix 1.1: Add missing assigned_at column to bands table
-- This column is used by the claim_band_atomic RPC but was missing from migration 002
ALTER TABLE bands ADD COLUMN IF NOT EXISTS assigned_at TIMESTAMPTZ;

-- Fix 1.2: Fix incidents.station_id foreign key type mismatch
-- stations.id is TEXT, but incidents.station_id was created as UUID in migration 005
-- We must drop the constraint, change the type, and re-add the constraint
ALTER TABLE incidents DROP CONSTRAINT IF EXISTS incidents_station_id_fkey;
ALTER TABLE incidents ALTER COLUMN station_id TYPE TEXT USING station_id::TEXT;
ALTER TABLE incidents ADD CONSTRAINT incidents_station_id_fkey FOREIGN KEY (station_id) REFERENCES stations(id);

-- Fix 1.3: Add missing columns to report_jobs table
-- These columns exist in supabase_schema.sql but were missing from migration 004
ALTER TABLE report_jobs ADD COLUMN IF NOT EXISTS requested_by UUID REFERENCES auth.users(id);
ALTER TABLE report_jobs ADD COLUMN IF NOT EXISTS format TEXT DEFAULT 'pdf';
ALTER TABLE report_jobs ADD COLUMN IF NOT EXISTS error_message TEXT;

-- ============================================================================
-- 2. RLS POLICY HARDENING
-- ============================================================================

-- ----------------------------------------------------------------------------
-- Table: token_claims
-- Issue: "Public Token Claims" FOR ALL USING (true) allowed full public read/write
-- Fix: Restrict to SELECT only. Mutations are handled exclusively by the RPC.
-- ----------------------------------------------------------------------------
DROP POLICY IF EXISTS "Public Token Claims" ON token_claims;

CREATE POLICY "Public Read Own Token" ON token_claims
FOR SELECT USING (true);

CREATE POLICY "Admin Full Access Tokens" ON token_claims
FOR ALL TO authenticated USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);

-- ----------------------------------------------------------------------------
-- Table: athletes
-- Issue: "Public Update Athlete via ID" FOR UPDATE USING (true) allowed public mutation
-- Fix: Remove public UPDATE. The claim_band_atomic RPC handles band_id update securely.
-- ----------------------------------------------------------------------------
DROP POLICY IF EXISTS "Public Update Athlete via ID" ON athletes;

-- ----------------------------------------------------------------------------
-- Table: bands
-- Issue: "Public Update Band Claim" FOR UPDATE USING (true) allowed public mutation
-- Fix: Remove public UPDATE. The claim_band_atomic RPC handles band assignment securely.
-- ----------------------------------------------------------------------------
DROP POLICY IF EXISTS "Public Update Band Claim" ON bands;

-- ----------------------------------------------------------------------------
-- Table: results
-- Issue: "Admin Update Results" FOR UPDATE TO authenticated USING (true) allowed any staff
-- Fix: Restrict UPDATE to admins only. Staff should only INSERT (append-only).
-- ----------------------------------------------------------------------------
DROP POLICY IF EXISTS "Admin Update Results" ON results;

CREATE POLICY "Admin Update Results" ON results
FOR UPDATE TO authenticated USING (
EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND role = 'admin')
);

-- ----------------------------------------------------------------------------
-- Table: profiles
-- Issue: "Public profiles are viewable by everyone." leaked staff names/roles
-- Fix: Restrict profile visibility to authenticated users only.
-- ----------------------------------------------------------------------------
DROP POLICY IF EXISTS "Public profiles are viewable by everyone." ON profiles;

CREATE POLICY "Authenticated Read Profiles" ON profiles
FOR SELECT TO authenticated USING (true);

-- ----------------------------------------------------------------------------
-- Table: device_status
-- Issue: "Public Device Status Update" FOR UPDATE USING (true) allowed public mutation
-- Fix: Restrict UPDATE to authenticated staff/admins. Stations can only INSERT (upsert).
-- ----------------------------------------------------------------------------
DROP POLICY IF EXISTS "Public Device Status Update" ON device_status;

CREATE POLICY "Staff Update Device Status" ON device_status
FOR UPDATE TO authenticated USING (true);
6 changes: 6 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const Home = lazy(() => import('./pages/Home'));
const Register = lazy(() => import('./pages/Register'));
const ClaimBand = lazy(() => import('./pages/ClaimBand'));
const StaffLogin = lazy(() => import('./pages/StaffLogin'));
const StaffStationSelect = lazy(() => import('./pages/StaffStationSelect'));
const StationMode = lazy(() => import('./pages/StationMode'));
const AdminLogin = lazy(() => import('./pages/AdminLogin'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
Expand All @@ -37,6 +38,11 @@ export default function App() {
<Route path="/p/:token" element={<ParentPortal />} />
<Route path="/claim-band" element={<ClaimBand />} />
<Route path="/staff/login" element={<StaffLogin />} />
<Route path="/staff/select-station" element={
<RouteGuard>
<StaffStationSelect />
</RouteGuard>
} />
<Route path="/staff/station/:stationId" element={
<RouteGuard>
<StationMode />
Expand Down
Loading