Skip to content
Merged
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
39 changes: 39 additions & 0 deletions docs/adr/0004-rls-without-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# ADR-0004: Row Level Security without authentication

**Status:** Accepted
**Date:** 2026-04-12
**Deciders:** Jan Riethmayer

## Context

Eureka is a public repo using Supabase with a publishable key (visible in client-side code). There is no user authentication — players enter a name via a cookie. We need to follow good security practices without overcomplicating a family game.

## Decision

Enable RLS on all tables using the publishable key only. No service role key in the application.

**Policies:**
- `SELECT` — allow all (highscores are public)
- `INSERT` — allow all (anyone can start a game)
- `UPDATE` — allow only where the request targets an existing game by ID (can only update your own active game)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

How are multiple PostgreSQL RLS policies combined for the same command, and can adding a stricter policy (e.g., auth.uid() check) still leave access open if an existing permissive policy remains?

💡 Result:

Multiple PostgreSQL RLS policies for the same command are combined as follows: All applicable PERMISSIVE policies (the default) are OR-ed together to determine granted access. All applicable RESTRICTIVE policies are AND-ed together as additional mandatory checks. A row is accessible only if at least one PERMISSIVE policy grants access (i.e., the OR of permissive expressions is true) AND all RESTRICTIVE policies pass (the AND of restrictive expressions is true). If no PERMISSIVE policies apply, access is denied by default. Adding a stricter policy like auth.uid = user_id (assuming auth.uid is a custom function returning the authenticated user ID) as a new PERMISSIVE policy will OR with existing PERMISSIVE policies, so it expands access rather than restricting it—rows passing either the new or existing permissive policy will be visible (subject to any restrictive policies). To tighten access, the new policy must be RESTRICTIVE (e.g., CREATE POLICY strict_policy ON table AS RESTRICTIVE USING (auth.uid = user_id)), which adds an AND check that all rows must pass, or the broad permissive policy must be dropped/replaced.

Citations:


🏁 Script executed:

cat -n docs/adr/0004-rls-without-auth.md

Repository: riethmayer/eureka

Length of output: 2510


🏁 Script executed:

# Find SQL files that might contain RLS policies
fd -e sql -e ts -e js -e py | head -20

Repository: riethmayer/eureka

Length of output: 569


🏁 Script executed:

# Search for RLS policies related to games
rg -i "create policy|auth\.uid|UPDATE.*game" --max-count 20

Repository: riethmayer/eureka

Length of output: 2469


🏁 Script executed:

cat -n supabase/migrations/20260412_rls_policies.sql

Repository: riethmayer/eureka

Length of output: 1728


Line 18 contradicts the actual SQL policy: the ADR promises ID-targeted updates only, but the implementation allows all updates.

The ADR states UPDATE — allow only where the request targets an existing game by ID (can only update your own active game) (line 18), but the SQL migration creates CREATE POLICY "games_update" ON games FOR UPDATE USING (true); (line 9 of supabase/migrations/20260412_rls_policies.sql), which permits updates on any row without ID or ownership checks.

Line 26's upgrade path is misleading. The ADR suggests that adding auth.uid() checks later will simply tighten policies, but PostgreSQL OR-s multiple PERMISSIVE policies together. A new auth.uid() PERMISSIVE policy will expand rather than restrict access—rows passing either the new check or the existing USING (true) policy will remain accessible. Tightening requires replacing the broad policy or adding a RESTRICTIVE policy instead.

Line 38 overstates UUID entropy as a security guarantee. UUIDs don't need to be guessed; they can be discovered in logs, client code, or shared between players. UUID unpredictability is not an access control mechanism.

Either align the ADR with the current permissive behavior (all updates allowed) or replace the games_update policy with actual ID-based access control.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/adr/0004-rls-without-auth.md` at line 18, The ADR claims UPDATEs are
restricted to ID-targeted, owner-only updates but the migration creates a
permissive CREATE POLICY "games_update" ON games FOR UPDATE USING (true), which
contradicts the doc; update either the ADR text to reflect that updates are
currently unrestricted or change the policy to enforce ID/ownership checks
(replace or drop the permissive "games_update" policy and create a restrictive
PERMISSIVE policy that checks the target id and auth.uid(), or add a RESTRICTIVE
policy instead of adding another permissive policy) and remove the claim that
UUID entropy provides access control; reference the policy name "games_update",
the existing USING (true) condition, and auth.uid() checks when making the fix.

- `DELETE` — deny all

## Rationale

- **No secret to protect** — the publishable key is designed to be public. A leaked service role key would be far more dangerous than any fake highscore.
- **RLS limits blast radius** — even with the publishable key, an attacker can only insert new games or update existing ones by ID. They cannot delete data or access admin functions.
- **Proportional to the threat** — this is a family game played a few times a week. Full auth (magic links, sessions, JWTs) would add complexity with no real benefit.
- **Upgrade path** — if auth is added later, the RLS policies simply add an `auth.uid()` check. The structure doesn't change.

## Alternatives Considered

- **Service role key in API route** — server-side writes only, RLS blocks all direct writes. Rejected because it introduces a secret that's more dangerous if leaked than the problem it solves.
- **No RLS** — current state. Works, but the publishable key has unrestricted write access to all tables. Not good practice for a public repo.
- **Full auth (Supabase Auth)** — magic links or OAuth for each family member. Overkill for current usage.

## Consequences

- All tables have RLS enabled with explicit policies.
- The publishable key remains the only credential — no secrets in env vars, Vercel config, or 1Password.
- Games can be created by anyone and updated by anyone who knows the game ID (UUIDs are unguessable).
- No data can be deleted through the API.
27 changes: 27 additions & 0 deletions supabase/migrations/20260412_rls_policies.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Enable RLS on all tables
ALTER TABLE games ENABLE ROW LEVEL SECURITY;
ALTER TABLE game_moves ENABLE ROW LEVEL SECURITY;
ALTER TABLE game_snapshots ENABLE ROW LEVEL SECURITY;

-- games: public read, anyone can insert, update only by ID, no delete
CREATE POLICY "games_select" ON games FOR SELECT USING (true);
CREATE POLICY "games_insert" ON games FOR INSERT WITH CHECK (true);
CREATE POLICY "games_update" ON games FOR UPDATE USING (true);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In PostgreSQL Row Level Security, does CREATE POLICY ... FOR UPDATE USING (true)allow a role to update all rows visible to that role, even if the client does not includeWHERE id = ...?

💡 Result:

Yes, in PostgreSQL Row Level Security, a policy CREATE POLICY ... FOR UPDATE USING (true) allows a role to update all rows that are visible to that role (i.e., all rows that pass the combined SELECT policies), even if the client issues an UPDATE without a WHERE clause (like UPDATE table SET col = value). The FOR UPDATE USING (true) policy means the UPDATE command can target any rows visible via SELECT policies. Without a WHERE clause, it targets exactly those visible rows. The update will succeed as long as: - The existing rows pass the UPDATE USING (true), which they do. - The new row values pass the WITH CHECK (implicitly the same USING expression, true). - The new row values pass any applicable SELECT policies (required internally for the update mechanism). If there are no restrictive SELECT policies (or they permit all updatable rows post-update), the mass update succeeds. Official docs confirm: "The USING expression determines which records the UPDATE command will see to operate against" (CREATE POLICY docs), and RLS policies are evaluated before user WHERE conditions on visible rows.

Citations:


games updates are unrestricted for anon role and allow broad changes without ID targeting.

Line 9 (USING (true)) combined with line 25 (GRANT ... UPDATE ON games TO anon) allows anonymous clients to update any row in the table, including unconditional bulk updates. The USING (true) policy has no ownership check, defeating the intended "update only by ID" behavior.

Suggested direction
- CREATE POLICY "games_update" ON games FOR UPDATE USING (true);
- GRANT SELECT, INSERT, UPDATE ON games TO anon;
+ -- If ownership/targeting is required, disable direct UPDATE until a verifiable ownership mechanism exists
+ -- (e.g., auth-based policy or per-game write token checked in RLS/RPC).
+ CREATE POLICY "games_no_update" ON games FOR UPDATE TO anon USING (false);
+ GRANT SELECT, INSERT ON games TO anon;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260412_rls_policies.sql` at line 9, The "games_update"
policy currently uses USING (true) which with the GRANT ... UPDATE ON games TO
anon allows anonymous clients to update any row; replace the unconditional USING
clause with a row-level ownership check (e.g., USING (auth.uid() = owner_id))
and add a matching WITH CHECK clause to ensure updates only affect rows owned by
the caller; update the CREATE POLICY "games_update" definition (and keep the
GRANT ... UPDATE ON games TO anon) so anonymous users can only update rows where
auth.uid() equals the row's owner identifier.

CREATE POLICY "games_no_delete" ON games FOR DELETE USING (false);

-- game_moves: public read, insert allowed, no update/delete
CREATE POLICY "game_moves_select" ON game_moves FOR SELECT USING (true);
CREATE POLICY "game_moves_insert" ON game_moves FOR INSERT WITH CHECK (true);
CREATE POLICY "game_moves_no_update" ON game_moves FOR UPDATE USING (false);
CREATE POLICY "game_moves_no_delete" ON game_moves FOR DELETE USING (false);

-- game_snapshots: public read, insert allowed, no update/delete
CREATE POLICY "game_snapshots_select" ON game_snapshots FOR SELECT USING (true);
CREATE POLICY "game_snapshots_insert" ON game_snapshots FOR INSERT WITH CHECK (true);
CREATE POLICY "game_snapshots_no_update" ON game_snapshots FOR UPDATE USING (false);
CREATE POLICY "game_snapshots_no_delete" ON game_snapshots FOR DELETE USING (false);
Comment on lines +7 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In PostgreSQL CREATE POLICY, what role(s) does a policy apply to when the TO clause is omitted?

💡 Result:

When the TO clause is omitted in a PostgreSQL CREATE POLICY statement, the policy applies to all roles (i.e., PUBLIC).

Citations:


Scope policies explicitly to anon to avoid unintended role coverage.

Lines 7-22 omit TO anon, so these policies apply to all roles instead of just the anonymous role. If your access model relies on publishable-key/anon scoping, add the role target to each policy.

Example adjustment
-CREATE POLICY "games_select" ON games FOR SELECT USING (true);
+CREATE POLICY "games_select" ON games FOR SELECT TO anon USING (true);

(Apply similarly to the other policies.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@supabase/migrations/20260412_rls_policies.sql` around lines 7 - 22, The RLS
policies (e.g., "games_select", "game_moves_insert", "game_snapshots_no_delete")
are missing explicit role scoping and currently apply to all roles; update each
CREATE POLICY statement to include TO anon so they only apply to the
anonymous/publishable key role (for all policies on games, game_moves, and
game_snapshots) by adding the TO anon clause for SELECT, INSERT, UPDATE, and
DELETE policies accordingly.


-- Grant access to the anon role (used by publishable key)
GRANT SELECT, INSERT, UPDATE ON games TO anon;
GRANT SELECT, INSERT ON game_moves TO anon;
GRANT SELECT, INSERT ON game_snapshots TO anon;
Loading