Commit f4eec81f authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(security): close coin minting and open write policies

Found while building the org/redeem layer; all exploitable with the public
anon key that ships in realtime.js, or with a player's own session.

- complete_match, award_coins and the match/queue functions were executable
  by anon and players: one REST call paid win coins to anyone. Service role
  only now; every caller is PHP with the service key.
- battlepass.php credited whatever "coins" the request carried, to anyone,
  as often as asked. The server now decides the reward, needs an active
  season and an unlocked tier, and pays each tier once.
- The legacy api/ludo.php let any player end any ludo match with any winners
  and get paid. It, api/domino.php and api/multiplayer.php were unused by the
  app and are removed.
- Shop, challenges and daily reward wrote balances with the player's token,
  which only worked because players could PATCH their own coins over REST.
  They write with the service key now; the shop deducts only if the balance
  is unchanged (a double tap cannot pay twice), refunds if the grant fails,
  and records its transaction (the old insert used a missing column).
- Policies: anyone could delete any ludo/domino/backgammon match, rewrite
  queues, groups and the theme, insert an "accepted" friendship with anyone
  and post into any chat. Writes are service-role only; every read and
  Realtime feed is unchanged. Group members read only their own memberships.
- profiles-guard.sql (applied after this deploy): a player's token may change
  only cosmetic profile fields.

Also the org-unify / redeem SQL layer and its contract (docs/redeem-contract.md).
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 0b076f00
......@@ -110,15 +110,29 @@ function generateTierReward(int $tier): array {
}
function claimTier($db, string $userId, array $input): void {
// The reward is the server's to decide. This used to credit whatever
// "coins" the request carried, to anyone, as often as they liked.
$tier = intval($input['tier'] ?? 0);
$coins = intval($input['coins'] ?? 0);
if ($tier < 1) jsonError('Invalid tier');
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : ['coins' => 0];
$newCoins = ($profile['coins'] ?? 0) + $coins;
$db->update('profiles', ['coins' => $newCoins], ['id' => 'eq.' . $userId]);
jsonResponse(['success' => true, 'coins' => $newCoins]);
$sdb = supabaseService();
$seasons = $sdb->get('seasons', ['is_active' => 'eq.true', 'select' => '*', 'limit' => 1]);
$s = (is_array($seasons) && !isset($seasons['error']) && !empty($seasons)) ? $seasons[0] : null;
if (!$s || (!empty($s['ends_at']) && strtotime($s['ends_at']) < time())) jsonError('No active season', 409);
$totalTiers = intval($s['total_tiers'] ?? 30);
$xpPerTier = max(1, intval($s['xp_per_tier'] ?? 100));
if ($tier > $totalTiers) jsonError('Invalid tier');
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'xp', 'limit' => 1]);
$xp = intval($profiles[0]['xp'] ?? 0);
if ($tier > intdiv($xp, $xpPerTier)) jsonError('Tier not unlocked yet', 403);
$reason = 'Battle pass ' . $s['id'] . ' tier ' . $tier;
$claimed = $sdb->get('economy_transactions', ['player_id' => 'eq.' . $userId, 'reason' => 'eq.' . $reason, 'select' => 'id', 'limit' => 1]);
if (is_array($claimed) && !isset($claimed['error']) && !empty($claimed)) jsonError('Already claimed', 409);
$reward = generateTierReward($tier)['free'];
if (($reward['type'] ?? '') !== 'coins') jsonResponse(['success' => true, 'reward' => $reward]);
$balance = supabaseRpc('award_coins', ['p_player_id' => $userId, 'p_amount' => intval($reward['amount']), 'p_reason' => $reason]);
jsonResponse(['success' => true, 'reward' => $reward, 'coins' => is_numeric($balance) ? (int)$balance : null]);
}
......@@ -170,13 +170,14 @@ function claimChallenge($db, string $userId, array $input): void {
}
// Grant rewards
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins,xp', 'limit' => 1]);
// Balances are written with the service key; players cannot write them.
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins,xp', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : ['coins' => 0, 'xp' => 0];
$newCoins = ($profile['coins'] ?? 0) + $coins;
$newXp = ($profile['xp'] ?? 0) + $xp;
$db->update('profiles', ['coins' => $newCoins, 'xp' => $newXp], ['id' => 'eq.' . $userId]);
$sdb->update('profiles', ['coins' => $newCoins, 'xp' => $newXp], ['id' => 'eq.' . $userId]);
// Record transaction
$sdb->insert('economy_transactions', [
......
......@@ -94,7 +94,7 @@ if ($method === 'POST') {
$newCoins = $currentCoins + $reward;
// Update profile: coins, streak, last_daily_reward
$db->update('profiles', [
$sdb->update('profiles', [
'coins' => $newCoins,
'daily_streak' => $streak,
'last_daily_reward' => $now
......
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
$db = supabase($token);
switch ($action) {
case 'create':
$room = $db->insert('domino_matches', [
'room_code' => generateRoomCode(),
'status' => 'waiting',
'players' => json_encode([$userId]),
'board' => '[]',
'hands' => '{}',
'scores' => '{}',
'created_by' => $userId
]);
jsonResponse($room[0] ?? $room);
break;
case 'join':
$roomCode = $input['room_code'] ?? '';
if (!$roomCode) jsonError('room_code required');
$match = $db->getOne('domino_matches', ['room_code' => 'eq.' . $roomCode, 'status' => 'eq.waiting']);
if (!$match) jsonError('Room not found', 404);
$players = json_decode($match['players'], true) ?: [];
if (in_array($userId, $players)) jsonError('Already in room');
$players[] = $userId;
$db->update('domino_matches', ['players' => json_encode($players)], ['id' => 'eq.' . $match['id']]);
jsonResponse(['success' => true, 'match_id' => $match['id']]);
break;
case 'play':
$matchId = $input['match_id'] ?? '';
$board = $input['board'] ?? null;
$hands = $input['hands'] ?? null;
if (!$matchId) jsonError('match_id required');
$update = [];
if ($board !== null) $update['board'] = json_encode($board);
if ($hands !== null) $update['hands'] = json_encode($hands);
if (!empty($update)) $db->update('domino_matches', $update, ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true]);
break;
case 'end':
$matchId = $input['match_id'] ?? '';
$scores = $input['scores'] ?? [];
if (!$matchId) jsonError('match_id required');
$db->update('domino_matches', [
'status' => 'completed',
'scores' => json_encode($scores)
], ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true]);
break;
default:
jsonError('Invalid action');
}
function generateRoomCode(): string {
return strtoupper(substr(bin2hex(random_bytes(3)), 0, 6));
}
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
$db = supabase($token);
switch ($action) {
case 'create':
$match = $db->insert('ludo_matches', [
'room_code' => strtoupper(substr(bin2hex(random_bytes(3)), 0, 6)),
'status' => 'waiting',
'players' => json_encode([$userId]),
'positions' => '{}',
'dice_value' => null,
'current_turn' => 0,
'winners' => '[]',
'created_by' => $userId
]);
jsonResponse($match[0] ?? $match);
break;
case 'join':
$roomCode = $input['room_code'] ?? '';
if (!$roomCode) jsonError('room_code required');
$match = $db->getOne('ludo_matches', ['room_code' => 'eq.' . $roomCode, 'status' => 'eq.waiting']);
if (!$match) jsonError('Room not found', 404);
$players = json_decode($match['players'], true) ?: [];
$players[] = $userId;
$db->update('ludo_matches', ['players' => json_encode($players)], ['id' => 'eq.' . $match['id']]);
jsonResponse(['success' => true, 'match_id' => $match['id']]);
break;
case 'roll':
$matchId = $input['match_id'] ?? '';
if (!$matchId) jsonError('match_id required');
$dice = random_int(1, 6);
$db->update('ludo_matches', ['dice_value' => $dice], ['id' => 'eq.' . $matchId]);
jsonResponse(['dice' => $dice]);
break;
case 'move':
$matchId = $input['match_id'] ?? '';
$positions = $input['positions'] ?? null;
$currentTurn = $input['current_turn'] ?? null;
if (!$matchId) jsonError('match_id required');
$update = ['last_activity' => date('c')];
if ($positions !== null) $update['positions'] = json_encode($positions);
if ($currentTurn !== null) $update['current_turn'] = $currentTurn;
if (!empty($update)) $db->update('ludo_matches', $update, ['id' => 'eq.' . $matchId]);
jsonResponse(['success' => true]);
break;
case 'end':
$matchId = $input['match_id'] ?? '';
$winners = $input['winners'] ?? [];
if (!$matchId) jsonError('match_id required');
$sdb = supabaseService();
$sdb->update('ludo_matches', [
'status' => 'completed',
'winners' => json_encode($winners),
'completed_at' => date('c')
], ['id' => 'eq.' . $matchId]);
supabaseRpc('complete_match', [
'p_game_key' => 'ludo',
'p_match_id' => $matchId,
'p_winners' => json_encode($winners),
'p_reason' => 'normal'
]);
jsonResponse(['success' => true]);
break;
default:
jsonError('Invalid action');
}
<?php
header('Content-Type: application/json');
require_once __DIR__ . '/../includes/cors.php';
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(200); exit; }
require_once __DIR__ . '/../includes/supabase.php';
require_once __DIR__ . '/../includes/auth.php';
$token = requireAuth();
$userId = getUserId($token);
$input = getInput();
$action = $input['action'] ?? '';
$gameKey = $input['game_key'] ?? '';
$validGames = ['chess', 'ludo', 'domino', 'backgammon'];
if ($gameKey && !in_array($gameKey, $validGames)) { jsonError('Invalid game_key', 400); }
function resolveTable(string $gameKey): string {
return match($gameKey) {
'chess' => 'matches',
'domino' => 'domino_matches',
'backgammon' => 'backgammon_matches',
default => 'ludo_matches',
};
}
switch ($action) {
case 'create_room':
$db = supabase($token);
$table = resolveTable($gameKey);
$result = null;
$code = '';
for ($attempt = 0; $attempt < 5; $attempt++) {
$code = strtoupper(substr(bin2hex(random_bytes(3)), 0, 6));
$existing = $db->get($table, ['room_code' => 'eq.' . $code, 'status' => 'eq.waiting', 'limit' => 1]);
if (!empty($existing) && !isset($existing['error'])) continue;
$result = $db->insert($table, [
'room_code' => $code,
'status' => 'waiting',
'players' => json_encode([$userId]),
'created_by' => $userId
]);
break;
}
if (!$result) jsonError('Failed to generate unique room code', 500);
jsonResponse(['room_code' => $code, 'match' => $result[0] ?? $result]);
break;
case 'join_room':
$roomCode = $input['room_code'] ?? '';
if (!$roomCode) jsonError('room_code required');
$sdb = supabaseService();
$table = resolveTable($gameKey);
$match = $sdb->get($table, ['room_code' => 'eq.' . $roomCode, 'status' => 'eq.waiting', 'select' => 'id,players,player_count', 'limit' => 1]);
if (!is_array($match) || empty($match) || isset($match['error'])) jsonError('Room not found', 404);
$match = $match[0];
$players = is_array($match['players']) ? $match['players'] : json_decode($match['players'] ?? '[]', true);
if (in_array($userId, $players)) {
jsonResponse(['success' => true, 'match_id' => $match['id']]);
break;
}
$maxPlayers = $match['player_count'] ?? ($gameKey === 'domino' ? 2 : 4);
if (count($players) >= $maxPlayers) jsonError('Room is full', 409);
$players[] = $userId;
$newStatus = count($players) >= $maxPlayers ? 'in_progress' : 'waiting';
$sdb->update($table, ['players' => $players, 'status' => $newStatus], [
'id' => 'eq.' . $match['id'],
'status' => 'eq.waiting'
]);
// Verify join succeeded (re-read to detect race)
$verify = $sdb->get($table, ['id' => 'eq.' . $match['id'], 'select' => 'players', 'limit' => 1]);
if (is_array($verify) && !empty($verify) && !isset($verify['error'])) {
$finalPlayers = is_array($verify[0]['players']) ? $verify[0]['players'] : json_decode($verify[0]['players'] ?? '[]', true);
if (!in_array($userId, $finalPlayers)) {
jsonError('Room is full', 409);
}
}
jsonResponse(['success' => true, 'match_id' => $match['id']]);
break;
default:
jsonError('Invalid action');
}
......@@ -26,43 +26,51 @@ if ($action === 'purchase') {
$cosmeticId = $input['cosmetic_id'] ?? '';
if (!$cosmeticId) jsonError('cosmetic_id required');
$items = $db->get('cosmetics', ['id' => 'eq.' . $cosmeticId, 'limit' => 1]);
// Money and ownership are written with the service key: players have no
// write rights on profiles' balances or on player_cosmetics.
$sdb = supabaseService();
$items = $sdb->get('cosmetics', ['id' => 'eq.' . $cosmeticId, 'limit' => 1]);
$item = is_array($items) && !empty($items) && !isset($items['error']) ? $items[0] : null;
if (!$item) jsonError('Item not found', 404);
$profiles = $db->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins,gems', 'limit' => 1]);
$profile = is_array($profiles) && !empty($profiles) ? $profiles[0] : ['coins' => 0, 'gems' => 0];
$coins = $profile['coins'] ?? 0;
$gems = $profile['gems'] ?? 0;
$priceCoin = $item['price_coins'] ?? 0;
$priceGem = $item['price_gems'] ?? 0;
if ($priceCoin > 0 && $coins < $priceCoin) jsonError('Insufficient coins');
if ($priceGem > 0 && $gems < $priceGem) jsonError('Insufficient gems');
$owned = $db->get('player_cosmetics', ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId, 'select' => 'id']);
$owned = $sdb->get('player_cosmetics', ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId, 'select' => 'id']);
if (!empty($owned) && !isset($owned['error'])) jsonError('Already owned');
if ($priceCoin > 0) {
$db->update('profiles', ['coins' => $coins - $priceCoin], ['id' => 'eq.' . $userId]);
$priceCoin = (int)($item['price_coins'] ?? 0);
$priceGem = (int)($item['price_gems'] ?? 0);
$currency = $priceCoin > 0 ? 'coins' : ($priceGem > 0 ? 'gems' : null);
$price = $currency === 'coins' ? $priceCoin : $priceGem;
$balanceAfter = null;
if ($currency) {
$profiles = $sdb->get('profiles', ['id' => 'eq.' . $userId, 'select' => 'coins,gems', 'limit' => 1]);
$have = (int)($profiles[0][$currency] ?? 0);
if ($have < $price) jsonError($currency === 'coins' ? 'Insufficient coins' : 'Insufficient gems');
// Only if the balance is still what we read: a double tap cannot pay twice.
$paid = $sdb->update('profiles', [$currency => $have - $price], ['id' => 'eq.' . $userId, $currency => 'eq.' . $have]);
if (!is_array($paid) || isset($paid['error']) || empty($paid)) jsonError('Balance changed, try again', 409);
$balanceAfter = $have - $price;
}
if ($priceGem > 0) {
$db->update('profiles', ['gems' => $gems - $priceGem], ['id' => 'eq.' . $userId]);
$grant = $sdb->insert('player_cosmetics', ['player_id' => $userId, 'cosmetic_id' => $cosmeticId, 'is_equipped' => false, 'acquired_via' => 'purchase']);
if (!is_array($grant) || isset($grant['error'])) {
// Give the money back rather than keep it for nothing.
if ($currency) $sdb->update('profiles', [$currency => $balanceAfter + $price], ['id' => 'eq.' . $userId]);
jsonError('Purchase failed', 500);
}
$db->insert('player_cosmetics', ['player_id' => $userId, 'cosmetic_id' => $cosmeticId, 'is_equipped' => false]);
if ($currency) {
$sdb->insert('economy_transactions', [
'player_id' => $userId,
'type' => 'purchase',
'currency' => $currency,
'amount' => -$price,
'balance_after' => $balanceAfter,
'reason' => 'Purchase: ' . ($item['name'] ?? $cosmeticId),
]);
}
$sdb = supabaseService();
$sdb->insert('economy_transactions', [
'player_id' => $userId,
'type' => 'purchase',
'currency' => $priceCoin > 0 ? 'coins' : 'gems',
'amount' => -($priceCoin > 0 ? $priceCoin : $priceGem),
'balance_after' => $priceCoin > 0 ? ($coins - $priceCoin) : ($gems - $priceGem),
'description' => 'Purchase: ' . ($item['name'] ?? $cosmeticId)
]);
jsonResponse(['success' => true, 'item' => $item]);
jsonResponse(['success' => true, 'item' => $item, 'balance' => $balanceAfter]);
}
if ($action === 'equip') {
......@@ -72,8 +80,11 @@ if ($action === 'equip') {
$item = $db->getOne('cosmetics', ['id' => 'eq.' . $cosmeticId, 'select' => 'type']);
if (!$item) jsonError('Item not found', 404);
$db->update('player_cosmetics', ['is_equipped' => false], ['player_id' => 'eq.' . $userId, 'cosmetic_id' => "not.eq.{$cosmeticId}"]);
$db->update('player_cosmetics', ['is_equipped' => true], ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId]);
$sdb = supabaseService();
$mine = $sdb->get('player_cosmetics', ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId, 'select' => 'id', 'limit' => 1]);
if (!is_array($mine) || isset($mine['error']) || empty($mine)) jsonError('Not owned', 403);
$sdb->update('player_cosmetics', ['is_equipped' => false], ['player_id' => 'eq.' . $userId, 'cosmetic_id' => "not.eq.{$cosmeticId}"]);
$sdb->update('player_cosmetics', ['is_equipped' => true], ['player_id' => 'eq.' . $userId, 'cosmetic_id' => 'eq.' . $cosmeticId]);
jsonResponse(['success' => true]);
}
......
This diff is collapsed.
-- Security lockdown, part 2 of 2. Apply only after the PHP that writes balances
-- with the service key is deployed (shop.php, challenges.php, battlepass.php,
-- daily-reward.php): until then those endpoints still write with the player's
-- token and would be refused.
--
-- "Users can update their own profile" has no column limits, so a player could
-- PATCH their own coins, gems, XP, ratings or ban flag over REST with the anon
-- key and their session. Policies cannot limit columns, so a trigger does: a
-- player's own token may change only the fields below. Everything else (and
-- any column added later) belongs to the server, which writes as service_role
-- or through SECURITY DEFINER functions, where current_user is not a client role.
BEGIN;
SET LOCAL lock_timeout = '5s';
CREATE OR REPLACE FUNCTION public.profiles_guard()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
editable text[] := ARRAY[
'username', 'display_name', 'display_name_ar', 'avatar_url', 'banner_url', 'bio', 'bio_ar',
'country_code', 'city', 'preferred_language', 'fide_id', 'fide_rating_standard',
'fide_rating_rapid', 'fide_rating_blitz', 'fide_title', 'avatar_border_color',
'is_online', 'last_seen_at', 'current_game', 'updated_at'];
BEGIN
IF current_user IN ('anon', 'authenticated')
AND (to_jsonb(NEW) - editable) IS DISTINCT FROM (to_jsonb(OLD) - editable) THEN
RAISE EXCEPTION 'Only the server can change these profile fields' USING ERRCODE = '42501';
END IF;
RETURN NEW;
END $$;
REVOKE EXECUTE ON FUNCTION public.profiles_guard() FROM PUBLIC, anon, authenticated;
DROP TRIGGER IF EXISTS profiles_guard ON public.profiles;
CREATE TRIGGER profiles_guard BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE FUNCTION public.profiles_guard();
COMMIT;
This diff is collapsed.
-- Security lockdown, part 1 of 2 (part 2: 2026-10-26-profiles-guard.sql).
--
-- The player app never talks to PostgREST with a player's token for writes it
-- cares about: every state change goes through PHP with the service key, and
-- the browser only listens on Realtime. Yet several policies let anyone holding
-- the public anon key (it ships in realtime.js) write or delete rows, and the
-- money functions were executable by anon. This removes those rights and keeps
-- every read (and so every Realtime feed) exactly as it was.
--
-- Backup of the schema, policies and grants before this ran:
-- /root/backups/2026-10-26-lockdown/public-schema-before.sql
BEGIN;
SET LOCAL lock_timeout = '5s';
-- 1. Money and match-state functions: service role only. Every caller is PHP
-- with the service key (supabaseRpc, $sdb->rpc).
DO $$
DECLARE f regprocedure;
BEGIN
FOR f IN
SELECT p.oid::regprocedure FROM pg_proc p
WHERE p.pronamespace = 'public'::regnamespace
AND p.proname IN ('complete_match', 'award_coins', 'enforce_turn_timeout', 'match_heartbeat',
'domino_table_join', 'domino_table_leave', 'domino_merge_state',
'cleanup_stale_matches', 'can_player_queue')
LOOP
EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC, anon, authenticated', f);
EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', f);
END LOOP;
END $$;
-- 2. Groups: were readable and writable by anyone. The service role manages
-- them; a player may read only their own memberships, which is what the
-- chat_messages read policy checks for group channels.
DROP POLICY IF EXISTS service_full_access_groups ON public.groups;
DROP POLICY IF EXISTS "Service manages groups" ON public.groups;
CREATE POLICY "Service manages groups" ON public.groups FOR ALL TO service_role USING (true) WITH CHECK (true);
DROP POLICY IF EXISTS "Members read their groups" ON public.groups;
CREATE POLICY "Members read their groups" ON public.groups FOR SELECT TO authenticated
USING (EXISTS (SELECT 1 FROM public.group_members gm WHERE gm.group_id = groups.id AND gm.user_id = auth.uid()));
DROP POLICY IF EXISTS service_full_access_group_members ON public.group_members;
DROP POLICY IF EXISTS "Service manages group members" ON public.group_members;
CREATE POLICY "Service manages group members" ON public.group_members FOR ALL TO service_role USING (true) WITH CHECK (true);
DROP POLICY IF EXISTS "Members read own memberships" ON public.group_members;
CREATE POLICY "Members read own memberships" ON public.group_members FOR SELECT TO authenticated USING (user_id = auth.uid());
-- 3. Match rows: the "write" policies were FOR ALL with USING (true), so anyone
-- could delete any ludo, domino or backgammon match. Reads stay public via
-- the separate *_read policies (Realtime depends on them).
DROP POLICY IF EXISTS backgammon_write ON public.backgammon_matches;
DROP POLICY IF EXISTS domino_matches_write ON public.domino_matches;
DROP POLICY IF EXISTS ludo_write ON public.ludo_matches;
DROP POLICY IF EXISTS "Players can create matches" ON public.matches;
DROP POLICY IF EXISTS "Players in match can update" ON public.matches;
-- 4. Queues: open to everyone for every command. Service role only.
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['arena_seekers', 'backgammon_queue', 'domino_queue', 'ludo_queue'] LOOP
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_all', t);
EXECUTE format('DROP POLICY IF EXISTS "Service manages %s" ON public.%I', t, t);
EXECUTE format('CREATE POLICY "Service manages %s" ON public.%I FOR ALL TO service_role USING (true) WITH CHECK (true)', t, t);
END LOOP;
END $$;
DROP POLICY IF EXISTS "Players can join queue" ON public.matchmaking_queue;
DROP POLICY IF EXISTS "Players can leave queue" ON public.matchmaking_queue;
DROP POLICY IF EXISTS matchmaking_update ON public.matchmaking_queue;
-- 5. Social writes go through friends.php, chat.php and groups.php. Directly,
-- a player could insert an already "accepted" friendship with anyone, or
-- post into any conversation or group channel they are not part of.
DROP POLICY IF EXISTS "Users can send friend requests" ON public.friendships;
DROP POLICY IF EXISTS users_send_friend_messages ON public.friend_messages;
DROP POLICY IF EXISTS "Authenticated users can send messages" ON public.chat_messages;
-- 6. Theme: anyone could rewrite it. Reads stay public (theme_read_all).
DROP POLICY IF EXISTS theme_write_service ON public.theme_settings;
DROP POLICY IF EXISTS "Service manages theme" ON public.theme_settings;
CREATE POLICY "Service manages theme" ON public.theme_settings FOR ALL TO service_role USING (true) WITH CHECK (true);
-- 7. The one table with RLS off.
ALTER TABLE public.mp_log ENABLE ROW LEVEL SECURITY;
COMMIT;
-- 2026-10-26 el3ab_tournament_rounds: signed-in players can no longer write rounds.
--
-- authenticated_write_tournament_rounds (FOR ALL TO authenticated USING true
-- WITH CHECK true) let any player rewrite pairings or results through the REST
-- API. Nothing legitimate needs it: public/js never writes this table (it only
-- subscribes to it over realtime), and every PHP writer (tournament-engine.php
-- via cron.php, tournament-match.php, tournament-admin.php, swiss.php,
-- game.php) and the manager use the service key.
--
-- Read access stays: tournament_rounds_viewable_by_everyone (SELECT, true),
-- which realtime needs. 2026-10-26-manager-columns.sql drops the same policy;
-- both files are safe to run in either order.
--
-- Idempotent.
begin;
drop policy if exists authenticated_write_tournament_rounds on public.el3ab_tournament_rounds;
do $$
begin
if not exists (select 1 from pg_policy
where polrelid = 'public.el3ab_tournament_rounds'::regclass
and polname = 'tournament_rounds_viewable_by_everyone') then
create policy tournament_rounds_viewable_by_everyone on public.el3ab_tournament_rounds
for select using (true);
end if;
end $$;
-- Belt and braces: the API roles keep SELECT only. TRUNCATE is not covered by RLS.
revoke insert, update, delete, truncate on public.el3ab_tournament_rounds from anon, authenticated;
commit;
notify pgrst, 'reload schema';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment