From eafe6d1b61f2d0ced2b68454648b8d6e50815d0d Mon Sep 17 00:00:00 2001 From: Yurii M Date: Wed, 16 Sep 2026 20:31:49 +0300 Subject: [PATCH] Harden public.profiles and bootstrap it for fresh databases - Reject role changes and non-default roles on insert unless the caller is already an admin; previously any signed-in user could promote themselves. - Stop exposing profiles.email to anon and authenticated; admins keep reading it through get_admin_users_list(). - Recreate public.profiles, guarded, in the first migration that references it, so migrations apply to a database built from the repo alone. Co-Authored-By: Claude Opus 5 --- dev-plans/supabase-pending-work-2026-08.md | 7 +++ src/components/AdminDashboard/useAdminData.ts | 2 +- ...60613000001_add_admin_utility_usage_fn.sql | 34 +++++++++++++ .../20260916000000_harden_profiles.sql | 51 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 supabase/migrations/20260916000000_harden_profiles.sql diff --git a/dev-plans/supabase-pending-work-2026-08.md b/dev-plans/supabase-pending-work-2026-08.md index 961eb1e..967cfb1 100644 --- a/dev-plans/supabase-pending-work-2026-08.md +++ b/dev-plans/supabase-pending-work-2026-08.md @@ -204,6 +204,13 @@ not apply — recheck 1d before assuming otherwise. ## Task 2 — close the `public.profiles` gap in the migration history +**Done 2026-09-16.** The definition was read from production and bootstrapped, +guarded, in `20260613000001`; every migration now applies to a bare scaffold. +Reading it also showed that any signed-in user could set their own `role` to +`admin`, and that anon could read every e-mail address — +`20260916000000_harden_profiles.sql` closes both and must be applied to +production. The notes below are kept for the record. + ### Background The `Supabase Preview` check builds a database from `supabase/migrations/` diff --git a/src/components/AdminDashboard/useAdminData.ts b/src/components/AdminDashboard/useAdminData.ts index 5fb37d3..04d31ab 100644 --- a/src/components/AdminDashboard/useAdminData.ts +++ b/src/components/AdminDashboard/useAdminData.ts @@ -146,7 +146,7 @@ export function useAdminData(activeTab: TabKey, history: Redirector) { // Fallback: fetch profiles directly to catch records that may not join with auth.users const {data: rawProfiles, error: profilesFallbackError} = await supabase .from('profiles') - .select('id, username, full_name, avatar_url, role, created_at, last_seen_at, email') + .select('id, username, full_name, avatar_url, role, created_at, last_seen_at') .order('created_at', {ascending: false}); if (profilesFallbackError) { diff --git a/supabase/migrations/20260613000001_add_admin_utility_usage_fn.sql b/supabase/migrations/20260613000001_add_admin_utility_usage_fn.sql index 9a817de..62b5485 100644 --- a/supabase/migrations/20260613000001_add_admin_utility_usage_fn.sql +++ b/supabase/migrations/20260613000001_add_admin_utility_usage_fn.sql @@ -27,6 +27,40 @@ begin end if; end $$; +-- public.profiles has the same history: created by hand in the dashboard, and +-- referenced by this and later migrations without ever being defined. Recreate +-- it only when missing, mirroring production as of 2026-09-16 (columns, +-- constraints, RLS and policies). Production is untouched; its grants are +-- tightened in 20260916000000_harden_profiles.sql. +do $$ +begin + if to_regclass('public.profiles') is null then + create table public.profiles ( + id uuid primary key references auth.users (id) on delete cascade, + username text unique, + avatar_url text, + bio text, + role text default 'user' check (role = any (array['user', 'author', 'admin'])), + created_at timestamptz default now(), + full_name text, + website text, + last_seen_at timestamptz default now(), + email text + ); + + alter table public.profiles enable row level security; + + create policy "Public profiles" on public.profiles + for select using (true); + create policy "Users can insert own profile" on public.profiles + for insert with check ((select auth.uid()) = id); + create policy update_profiles_combined on public.profiles + for update + using (((select auth.uid()) = id) or public.is_admin()) + with check (((select auth.uid()) = id) or public.is_admin()); + end if; +end $$; + -- Admin-only per-user utility usage breakdown. Joins usage rows with profile -- identity so an admin can see who launched what. Guarded by is_admin(); -- SECURITY DEFINER is required to read across all users past RLS. diff --git a/supabase/migrations/20260916000000_harden_profiles.sql b/supabase/migrations/20260916000000_harden_profiles.sql new file mode 100644 index 0000000..95da71b --- /dev/null +++ b/supabase/migrations/20260916000000_harden_profiles.sql @@ -0,0 +1,51 @@ +-- Close two holes in public.profiles. +-- +-- 1. Self-promotion. update_profiles_combined lets a user update their own row, +-- and the API roles hold UPDATE on every column, including role. Any +-- signed-in user could therefore run +-- update profiles set role = 'admin' where id = auth.uid() +-- and pass is_admin() everywhere. The INSERT policy had the same gap for a +-- user whose profile row did not exist yet. +-- A BEFORE trigger now rejects any role other than the default unless the +-- caller is already an admin. Only the API roles are checked, so the +-- dashboard, service_role and security definer functions are unaffected. +-- +-- 2. E-mail exposure. "Public profiles" is `using (true)` for every role, so +-- anon could list every user's e-mail address through PostgREST. SELECT is +-- now granted per column, without email. Admins read e-mail through +-- get_admin_users_list(), which is security definer. Client code must name +-- its columns: `select('*')` on profiles now fails. + +create or replace function public.guard_profile_role() +returns trigger +language plpgsql +set search_path = public +as $$ +begin + if current_user not in ('anon', 'authenticated') then + return new; + end if; + + if tg_op = 'INSERT' then + if new.role is distinct from 'user' and not public.is_admin() then + raise exception 'Only admins can assign roles' using errcode = '42501'; + end if; + elsif new.role is distinct from old.role and not public.is_admin() then + raise exception 'Only admins can change roles' using errcode = '42501'; + end if; + + return new; +end; +$$; + +revoke execute on function public.guard_profile_role() from public, anon, authenticated; + +drop trigger if exists trg_guard_profile_role on public.profiles; +create trigger trg_guard_profile_role + before insert or update of role on public.profiles + for each row + execute function public.guard_profile_role(); + +revoke select on public.profiles from anon, authenticated; +grant select (id, username, avatar_url, bio, role, created_at, full_name, website, last_seen_at) + on public.profiles to anon, authenticated;