Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
"Bash(npx --yes js-yaml .github/workflows/security.yml)",
"Skill(claude-api)",
"Bash(deno --version)",
"Bash(git check-ignore *)"
"Bash(git check-ignore *)",
"Bash(sort -k5 -n -r)"
]
}
}
2 changes: 1 addition & 1 deletion hardening_migration.sql
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ BEGIN
-- Validate source_type against the CHECK constraint domain.
-- Mirrors migrations/007a_add_source_type.sql so a malformed payload
-- never reaches the insert and never trips a 23514 CHECK error.
IF p_source_type NOT IN ('manual', 'live_ble', 'imported_csv', 'webhook') THEN
IF p_source_type NOT IN ('manual', 'live_ble', 'imported_csv') THEN
RETURN jsonb_build_object('success', false, 'error', 'invalid source_type');
END IF;

Expand Down
29 changes: 20 additions & 9 deletions migrations/007a_add_source_type.sql
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
-- back-fill it to align with the new four-value domain.
-- 2. Drops the legacy CHECK constraint installed by mig 019 (which
-- allowed only `'live_ble' | 'manual_staff'`).
-- 3. Installs the new CHECK constraint with the spec's full domain:
-- 'manual' | 'live_ble' | 'imported_csv' | 'webhook'
-- 3. Installs the new CHECK constraint with the canonical 3-value domain:
-- 'manual' | 'live_ble' | 'imported_csv'
-- 4. Sets the column DEFAULT to `'manual'` (the safe, conservative default
-- — never claim hardware verification by accident).
-- 5. Creates the single-column `idx_results_source_type` for the
Expand All @@ -47,26 +47,37 @@ ALTER TABLE results
-- 2. Drop the legacy CHECK constraint --------------------------------------
--
-- Mig 019 installed `results_source_type_check` with the two-value domain
-- ('live_ble', 'manual_staff'). The new four-value domain supersedes it.
-- ('live_ble', 'manual_staff'). The canonical 3-value domain supersedes it.
ALTER TABLE results
DROP CONSTRAINT IF EXISTS results_source_type_check;

-- 3. Back-fill legacy values onto the new domain ---------------------------
-- 3. Back-fill legacy values onto the canonical 3-value domain -------------
--
-- 'manual_staff' → 'manual' (one-to-one mapping; the new domain drops the
-- now-redundant '_staff' suffix). 'live_ble' is preserved unchanged.
-- 'manual_staff' → 'manual' (drops the redundant '_staff' suffix)
-- 'legacy_csv' → 'imported_csv' (clarifies the canonical CSV-ingest tag
-- installed by mig 020 / 024)
-- 'live_ble' is preserved unchanged.
--
-- Both UPDATEs are idempotent (`WHERE source_type = '<legacy>'`) — re-running
-- is a no-op once the domain is canonicalised. CRITICAL: both migrations
-- must run BEFORE the new CHECK constraint below; otherwise PG's
-- ADD CONSTRAINT validation pass blocks on the legacy strings.
UPDATE results
SET source_type = 'manual'
WHERE source_type = 'manual_staff';

UPDATE results
SET source_type = 'imported_csv'
WHERE source_type = 'legacy_csv';

-- 4. Realign the column DEFAULT to the new safe value ----------------------
ALTER TABLE results
ALTER COLUMN source_type SET DEFAULT 'manual';

-- 5. Install the new CHECK constraint --------------------------------------
-- 5. Install the new CHECK constraint — canonical 3-value domain ----------
ALTER TABLE results
ADD CONSTRAINT results_source_type_check
CHECK (source_type IN ('manual', 'live_ble', 'imported_csv', 'webhook'));
CHECK (source_type IN ('manual', 'live_ble', 'imported_csv'));

-- 6. Single-column index per spec ------------------------------------------
--
Expand All @@ -79,7 +90,7 @@ CREATE INDEX IF NOT EXISTS idx_results_source_type

-- 7. Documentation ---------------------------------------------------------
COMMENT ON COLUMN results.source_type IS
'Provenance discriminator: manual | live_ble | imported_csv | webhook. ' ||
'Provenance discriminator: manual | live_ble | imported_csv. ' ||
'Required on every insert via submit_result_secure. Hardware-verified ' ||
'rows (live_ble) are the only ones eligible for the cryptographic ' ||
'verification_hash signing path.';
Expand Down
36 changes: 28 additions & 8 deletions migrations/015_composite_uniqueness_guard.sql
Original file line number Diff line number Diff line change
Expand Up @@ -78,21 +78,41 @@ END $$;
-- WHERE athlete_id = p_athlete_id
-- AND drill_type = p_drill_type
-- AND recorded_at > now() - interval '120 seconds'
-- ORDER BY recorded_at DESC
-- LIMIT 1
--
-- Without an index, this is a sequential scan. With 10,000+ results across a
-- busy combine day, this adds measurable latency to every result submission.
-- This partial index covers exactly the hot path: recent rows only.
--
-- IMMUTABILITY CONSTRAINT:
-- PostgreSQL forbids volatile functions (now(), CURRENT_TIMESTAMP, random())
-- in CREATE INDEX predicates — managed Postgres providers (Supabase included)
-- reject such migrations at provisioning time with
-- `ERROR: functions in index predicate must be marked IMMUTABLE`.
-- The previous predicate `WHERE recorded_at > (now() - interval '24 hours')`
-- triggered exactly that failure on every fresh provision.
--
-- DESIGN:
-- Use a stable compound B-Tree on (athlete_id, drill_type, recorded_at DESC).
-- Postgres uses the leading two equality columns to seek and the trailing
-- recorded_at DESC to satisfy the ORDER BY ... LIMIT 1 directly from the
-- index — no sort, no heap scan beyond the single matching tuple. Bounding
-- `recorded_at > now() - interval '120 seconds'` becomes a cheap range scan
-- on the trailing index column, so the original hot-path latency goal is
-- preserved without any volatile predicate.
--
-- The optional `WHERE voided IS DISTINCT FROM true` predicate keeps the
-- index sparse: voided rows are explicitly excluded from suspicious-duplicate
-- detection (see the matching filter inside submit_result_secure below), so
-- they should not occupy index pages either. `IS DISTINCT FROM` correctly
-- handles NULL (Postgres treats `voided = false` and `voided IS NULL` as
-- "not voided"), and it is an immutable boolean expression — index-safe.
--
-- IF NOT EXISTS: idempotent.
-- ---------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_results_recent_by_athlete_drill
CREATE INDEX IF NOT EXISTS idx_results_athlete_drill_time
ON results (athlete_id, drill_type, recorded_at DESC)
WHERE recorded_at > (now() - interval '24 hours');

-- NOTE: The partial index predicate uses a static expression. PostgreSQL
-- evaluates it at index creation time, not at query time. For a combine that
-- runs within a single calendar day this is correct. For multi-day events,
-- run this migration again at the start of each day to refresh the index.
WHERE voided IS DISTINCT FROM true;

-- ---------------------------------------------------------------------------
-- Step 3: submit_result_secure v4 — adds suspicious-duplicate detection.
Expand Down
197 changes: 197 additions & 0 deletions migrations/016a_athlete_lookup_rpc.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
-- =============================================================================
-- MIGRATION 016a: Athlete Lookup RPC + Public-Read Eradication
-- Core Elite Combine 2026 · Mission "lookup_athlete_by_phone"
-- =============================================================================
--
-- FILENAME NOTE: the mission spec asked for `migrations/016_athlete_lookup_rpc.sql`
-- but slot `016_tier1_data_hardening.sql` is already taken by the Tier-1 data
-- hardening work (DOB constraints, parent-email format, dedup unique index).
-- Per the established 007a/008a/009a/010a/011a convention used everywhere
-- else in this repo, we use `016a` so the lexical sort still applies the
-- migrations in the correct order (016 → 016a → 017).
--
-- WHY this exists:
-- The /lookup page lets parents type a phone number and receive their
-- child's wristband number. Until this migration, the page hit the
-- `athletes` table directly via `supabase.from('athletes').select(...)`
-- which required a permissive RLS policy. The original `Public Read Own
-- Athlete` policy (mig 010, line 33–36) granted `USING (true)` SELECT to
-- the default/public role — meaning any anonymous client could enumerate
-- the entire athletes table, scrape parent_phone, parent_email, DOB, and
-- build a contact list. That is a single-policy data breach.
--
-- WHAT this does:
-- 1. Drops the `Public Read Own Athlete` USING(true) policy so anon/public
-- can no longer issue arbitrary SELECTs against `athletes`.
-- 2. Creates `athlete_lookup_attempts` — an append-only ledger that backs
-- the rate-limit gate inside the RPC (max 10 attempts per phone per
-- 5-minute window). RLS is enabled with no public/anon policies; only
-- the SECURITY DEFINER RPC writes here.
-- 3. Creates `lookup_athlete_by_phone(p_phone TEXT, p_event_id UUID)` —
-- the only sanctioned public path to athlete data on this page.
-- • SECURITY DEFINER with explicit `SET search_path = public, pg_temp`
-- to neutralize search-path injection.
-- • Validates phone format (10 digits, normalized server-side).
-- • Enforces the rate limit before any read.
-- • Returns ONLY {id, first_name, last_name, event_id, band_display} —
-- deliberately omits parent_phone, parent_email, DOB, position,
-- height/weight, etc. so a successful call exposes the minimum
-- needed to identify a wristband.
-- • Scoped to a single event via `p_event_id` so a parent must already
-- know which event their child is registered for; cross-event
-- scraping is blocked.
-- 4. REVOKEs PUBLIC, GRANTs EXECUTE only to anon + authenticated.
--
-- WHAT this does NOT touch:
-- • `athletes_anon_public_select` (from 010a_rls_hardening.sql line 248):
-- this is narrowly scoped to is_public events via an EXISTS subquery —
-- it does NOT use `USING (true)` and falls outside the explicit
-- anti-pattern. The scout pages (Leaderboard, AthleteDetail) depend on
-- it and live outside Mission scope.
-- • `Staff Read Athletes` (TO authenticated USING (true)) and the
-- tenant-scoped policies installed by 010a — staff still need broad
-- read access via the admin app; the Mission anti-pattern explicitly
-- excludes authenticated roles.
--
-- IDEMPOTENCY: Re-runnable.
-- DROP POLICY IF EXISTS, CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT
-- EXISTS, CREATE OR REPLACE FUNCTION, REVOKE/GRANT are all idempotent.
-- =============================================================================

BEGIN;

-- ---------------------------------------------------------------------------
-- 1. Drop the USING(true) public SELECT policy on athletes.
-- This is the exact target the Mission anti-pattern names. Mig 010
-- installed it as a temporary bridge ("Public SELECT remains open —
-- parent portal and lookup pages need it"). The new RPC supersedes that
-- bridge.
-- ---------------------------------------------------------------------------
DROP POLICY IF EXISTS "Public Read Own Athlete" ON athletes;

-- Defensive: also drop any historical names that mig 002 / 001 may have
-- installed under different casings. None of these should still exist on a
-- fully-migrated database; the IF EXISTS guard makes the line a no-op when
-- they don't.
DROP POLICY IF EXISTS "Public Read Athletes" ON athletes;
DROP POLICY IF EXISTS "Public Insert Athletes" ON athletes;

-- ---------------------------------------------------------------------------
-- 2. Rate-limit ledger.
-- Append-only. The RPC inserts one row per attempt and counts rows in
-- the trailing 5-minute window. Service role + the SECURITY DEFINER RPC
-- are the only writers; no anon/public/authenticated policies.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS athlete_lookup_attempts (
id BIGSERIAL PRIMARY KEY,
phone_digits TEXT NOT NULL,
event_id UUID,
attempted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
matched_count INT
);

-- Hot path: WHERE phone_digits = $1 AND attempted_at > now() - interval '...'
-- The trailing recorded_at DESC lets the LIMIT 1 / count(*) seek directly.
CREATE INDEX IF NOT EXISTS idx_athlete_lookup_attempts_phone_time
ON athlete_lookup_attempts (phone_digits, attempted_at DESC);

ALTER TABLE athlete_lookup_attempts ENABLE ROW LEVEL SECURITY;
ALTER TABLE athlete_lookup_attempts FORCE ROW LEVEL SECURITY;
-- (Intentionally NO policies. SECURITY DEFINER RPC + service role only.)

-- ---------------------------------------------------------------------------
-- 3. lookup_athlete_by_phone — the only sanctioned public read path.
--
-- Return shape is fixed to the Mission spec:
-- id, first_name, last_name, event_id, band_display
-- Anything beyond that (parent_phone, parent_email, DOB, position,
-- height/weight) is excluded by design.
--
-- `SET search_path = public, pg_temp` is mandatory for SECURITY DEFINER
-- functions that reference unqualified relation names — without it a
-- malicious caller with CREATE on a temp/parallel schema could shadow
-- `athletes` / `bands` and harvest writes. `pg_temp` is appended last
-- per the standard CVE-2018-1058 mitigation pattern.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION lookup_athlete_by_phone(
p_phone TEXT,
p_event_id UUID
)
RETURNS TABLE (
id UUID,
first_name TEXT,
last_name TEXT,
event_id UUID,
band_display TEXT
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
v_digits TEXT;
v_recent_attempts INT;
BEGIN
-- ── Input validation ─────────────────────────────────────────────────────
IF p_phone IS NULL OR p_event_id IS NULL THEN
RAISE EXCEPTION 'phone and event_id are required'
USING ERRCODE = '22023'; -- invalid_parameter_value
END IF;

-- Normalize: strip every non-digit. Defense in depth — the client also
-- normalizes, but never trust client-side stripping.
v_digits := regexp_replace(p_phone, '\D', '', 'g');

IF length(v_digits) <> 10 THEN
RAISE EXCEPTION 'phone must contain exactly 10 digits'
USING ERRCODE = '22023';
END IF;

-- ── Rate limit ───────────────────────────────────────────────────────────
-- 10 attempts per phone per 5 minutes. Sized to absorb a parent
-- mistyping a few times while shutting down a scraper that's iterating
-- through area codes.
SELECT count(*)
INTO v_recent_attempts
FROM athlete_lookup_attempts
WHERE phone_digits = v_digits
AND attempted_at > now() - interval '5 minutes';

IF v_recent_attempts >= 10 THEN
RAISE EXCEPTION 'rate limit exceeded — please try again in a few minutes'
USING ERRCODE = '53400'; -- configuration_limit_exceeded
END IF;

-- Record the attempt BEFORE the SELECT so a successful flood still
-- accrues against the limit (vs. only failed attempts).
INSERT INTO athlete_lookup_attempts (phone_digits, event_id)
VALUES (v_digits, p_event_id);

-- ── Narrow read ──────────────────────────────────────────────────────────
RETURN QUERY
SELECT a.id,
a.first_name,
a.last_name,
a.event_id,
b.display_number::TEXT AS band_display
FROM athletes a
LEFT JOIN bands b
ON b.band_id = a.band_id
WHERE a.parent_phone = v_digits
AND a.event_id = p_event_id
ORDER BY a.created_at DESC
LIMIT 5;
END;
$$;

-- Lock down execution: no PUBLIC, only the two real roles that hit PostgREST.
REVOKE ALL ON FUNCTION lookup_athlete_by_phone(TEXT, UUID) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION lookup_athlete_by_phone(TEXT, UUID) TO anon, authenticated;

COMMENT ON FUNCTION lookup_athlete_by_phone(TEXT, UUID) IS
'Public parent-portal lookup. Returns at most 5 athlete identity rows ' ||
'(id, first_name, last_name, event_id, band_display) for a phone+event ' ||
'pair. Rate-limited at 10/5min/phone via athlete_lookup_attempts. ' ||
'SECURITY DEFINER + SET search_path defends against CVE-2018-1058.';

COMMIT;
16 changes: 8 additions & 8 deletions migrations/019_verification_hash.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
-- 1. results.verification_hash TEXT (nullable)
-- Populated by the generate-verified-export Edge Function.
-- HMAC-SHA-256(VERIFICATION_SECRET, canonical_payload_string).
-- NULL = result has not been verified yet, or source_type = 'manual_staff'.
-- NULL = result has not been verified yet, or source_type = 'manual'.
--
-- 2. results.source_type TEXT NOT NULL DEFAULT 'manual_staff'
-- 2. results.source_type TEXT NOT NULL DEFAULT 'manual'
-- Set by the client at write time.
-- 'live_ble' — result was captured via BLE hardware; eligible for
-- cryptographic verification.
-- 'manual_staff' — result was entered manually; can never be
-- 'manual' — result was entered manually; can never be
-- hardware-verified; verification_hash remains NULL.
--
-- 3. results.session_id TEXT (nullable)
Expand All @@ -40,7 +40,7 @@
-- Also embedded in the verification hash payload.
--
-- 6. submit_result_secure v6 — adds p_source_type and p_session_id params.
-- Existing callers without the new params get 'manual_staff' / NULL.
-- Existing callers without the new params get 'manual' / NULL.
--
-- 7. upsert_capture_telemetry_lww v2 — adds p_clock_offset_ms and p_rtt_ms.
-- Existing callers default to NULL for both.
Expand All @@ -57,7 +57,7 @@ BEGIN;
-- ---------------------------------------------------------------------------

ALTER TABLE results ADD COLUMN IF NOT EXISTS verification_hash TEXT;
ALTER TABLE results ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'manual_staff';
ALTER TABLE results ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'manual';
ALTER TABLE results ADD COLUMN IF NOT EXISTS session_id TEXT;

-- Check constraint: only valid source types
Expand All @@ -69,15 +69,15 @@ BEGIN
) THEN
ALTER TABLE results
ADD CONSTRAINT results_source_type_check
CHECK (source_type IN ('live_ble', 'manual_staff'));
CHECK (source_type IN ('live_ble', 'manual'));
END IF;
END $$;

-- Back-fill source_type for existing rows:
-- If a result has a matching capture_telemetry row, it was hardware-captured.
UPDATE results r
SET source_type = 'live_ble'
WHERE source_type = 'manual_staff'
WHERE source_type = 'manual'
AND EXISTS (
SELECT 1 FROM capture_telemetry ct WHERE ct.result_id = r.id
);
Expand Down Expand Up @@ -121,7 +121,7 @@ CREATE OR REPLACE FUNCTION submit_result_secure(
p_attempt_number INT DEFAULT 1,
p_meta JSONB DEFAULT '{}'::jsonb,
p_device_timestamp BIGINT DEFAULT 0,
p_source_type TEXT DEFAULT 'manual_staff',
p_source_type TEXT DEFAULT 'manual',
p_session_id TEXT DEFAULT NULL
) RETURNS JSONB
LANGUAGE plpgsql
Expand Down
Loading
Loading