When to use it
Admins need to see or manage data beyond their own, for example the list of
users with their role and billing state. The steps use a /users page as the
example.
Rules to keep
- The admin app is not a security boundary by itself: every page and Route
Handler calls
getCurrentAdmin()and stops without an admin. A check only in a layout is not enough, because layouts do not re-render on navigation and do not stop the page from rendering. - The role is read on the server from
profilesthrough RLS, never from a cookie, a query parameter or a client flag. - Admins read other users' rows through RLS policies, with the anon key and the
session. Do not use a service-role key in
apps/admin: it bypasses RLS, so a mistake in a page would expose every row. - No policy may let users change
profiles.role. Promote an admin with SQL from the Supabase dashboard or a migration.
Steps
Add a migration in
supabase/migrations/(append-only) with a helper that tells whether the caller is an admin, and the policies the page needs. A policy onprofilescannot queryprofilesdirectly (Postgres reports an infinite recursion), so the helper issecurity definer:create function public.is_admin() returns boolean language sql stable security definer set search_path = '' as $$ select exists ( select 1 from public.profiles where id = (select auth.uid()) and role = 'admin' ); $$; revoke execute on function public.is_admin() from public, anon; grant execute on function public.is_admin() to authenticated; create policy "admins read all profiles" on public.profiles for select to authenticated using ((select public.is_admin())); create policy "admins read all billing accounts" on public.billing_accounts for select to authenticated using ((select public.is_admin()));The helper only answers about the caller, so it is safe to call over the API. Add only
selectpolicies unless the page really changes data.In
apps/admin/lib/current_admin.tsmove the creation of the Supabase client into an exportedcreateAdminSupabaseClient()and use it ingetCurrentAdmin(), so pages query with the same per-request client.Create
apps/admin/app/users/page.tsxas a Server Component. CallgetCurrentAdmin()first and, without an admin, render the same "Access denied" asapps/admin/app/page.tsxbefore any query. Then read withcreateAdminSupabaseClient(), for examplefrom('profiles').select('id, email, role, billing_accounts(status)'), and render only the fields the page shows.If the page changes data, use a Server Action or a Route Handler that calls
getCurrentAdmin()again, and add an RLS policy for that operation that also usespublic.is_admin().Apply the migration and check with two accounts: a
membersees "Access denied" on/users, and aselectonprofileswith their session still returns only their own row; anadminsees every user.Describe the new policies in
DATABASE.mdand what admins can do inPRODUCT.md.
Done when
- the verification commands pass;
- the page and every action on it call
getCurrentAdmin()on the server; - a
membergets no extra rows from the API or the database, checked as in step 5; DATABASE.mdandPRODUCT.mddescribe the change.
