-
Notifications
You must be signed in to change notification settings - Fork 1
feat: enable RLS policies on all tables #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| - `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. | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 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:
Line 9 ( 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 |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 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 Lines 7-22 omit 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 |
||
|
|
||
| -- 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; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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:
Repository: riethmayer/eureka
Length of output: 2510
🏁 Script executed:
Repository: riethmayer/eureka
Length of output: 569
🏁 Script executed:
Repository: riethmayer/eureka
Length of output: 2469
🏁 Script executed:
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 createsCREATE POLICY "games_update" ON games FOR UPDATE USING (true);(line 9 ofsupabase/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 newauth.uid()PERMISSIVE policy will expand rather than restrict access—rows passing either the new check or the existingUSING (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_updatepolicy with actual ID-based access control.🤖 Prompt for AI Agents