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
7 changes: 7 additions & 0 deletions dev-plans/supabase-pending-work-2026-08.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down
2 changes: 1 addition & 1 deletion src/components/AdminDashboard/useAdminData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
34 changes: 34 additions & 0 deletions supabase/migrations/20260613000001_add_admin_utility_usage_fn.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions supabase/migrations/20260916000000_harden_profiles.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading