feat: enhance service role handling and storage policies in migrations and schemas

This commit is contained in:
Poyraz Avsever
2026-06-14 12:52:11 +03:00
parent b1620d4748
commit da8c55d1ad
10 changed files with 250 additions and 14 deletions
+4 -1
View File
@@ -96,6 +96,9 @@ services:
SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in Dokploy env} SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in Dokploy env}
POSTGREST_URL: http://neta-rest:3000 POSTGREST_URL: http://neta-rest:3000
PGRST_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy env} PGRST_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy env}
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy env}
AUTH_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy env}
SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in Dokploy env}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy env}@neta-db:5432/postgres DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy env}@neta-db:5432/postgres
FILE_SIZE_LIMIT: ${STORAGE_FILE_SIZE_LIMIT:-52428800} FILE_SIZE_LIMIT: ${STORAGE_FILE_SIZE_LIMIT:-52428800}
STORAGE_BACKEND: file STORAGE_BACKEND: file
@@ -115,7 +118,7 @@ services:
neta-storage: neta-storage:
condition: service_started condition: service_started
environment: environment:
NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.2" NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.3"
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy env}@neta-db:5432/postgres DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy env}@neta-db:5432/postgres
NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2} NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2}
volumes: volumes:
+4 -1
View File
@@ -101,6 +101,9 @@ services:
SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in .env} SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in .env}
POSTGREST_URL: http://neta-rest:3000 POSTGREST_URL: http://neta-rest:3000
PGRST_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env} PGRST_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env}
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env}
AUTH_JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env}
SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in .env}
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres
FILE_SIZE_LIMIT: ${STORAGE_FILE_SIZE_LIMIT:-52428800} FILE_SIZE_LIMIT: ${STORAGE_FILE_SIZE_LIMIT:-52428800}
STORAGE_BACKEND: file STORAGE_BACKEND: file
@@ -121,7 +124,7 @@ services:
neta-storage: neta-storage:
condition: service_started condition: service_started
environment: environment:
NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.2" NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.3"
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres
NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2} NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2}
volumes: volumes:
@@ -0,0 +1,18 @@
# 0011 - Service Role Claims And Storage Policies
SQL file:
`supabase/migrations/0011_fix_service_role_claims_and_storage_policies.sql`
## Purpose
Fixes service-role detection for self-hosted PostgREST deployments and allows trusted server-side storage operations to pass RLS.
PostgREST stores JWT claims in `request.jwt.claims` JSON when legacy GUCs are disabled. Earlier SQL only checked the legacy `request.jwt.claim.role` value, so service-role requests could be rejected.
## Changes
- Adds `public.neta_current_jwt_role()`.
- Updates `public.request_internal_auth_creation()` to read both modern and legacy JWT claim formats.
- Recreates avatar and project asset storage policies to allow service-role operations.
- Reloads the PostgREST schema cache.
+2 -1
View File
@@ -14,6 +14,7 @@ This file is the canonical order of SQL files for database setup and migration.
| 0008 | `supabase/migrations/0008_add_project_progress_and_quota.sql` | `docs/database/0008-project-progress-and-quota.md` | Pending execution | | 0008 | `supabase/migrations/0008_add_project_progress_and_quota.sql` | `docs/database/0008-project-progress-and-quota.md` | Pending execution |
| 0009 | `supabase/migrations/0009_lock_registration_after_first_admin.sql` | `docs/database/0009-lock-registration-after-first-admin.md` | Pending execution | | 0009 | `supabase/migrations/0009_lock_registration_after_first_admin.sql` | `docs/database/0009-lock-registration-after-first-admin.md` | Pending execution |
| 0010 | `supabase/migrations/0010_allow_internal_auth_user_creation.sql` | `docs/database/0010-internal-auth-user-creation.md` | Pending execution | | 0010 | `supabase/migrations/0010_allow_internal_auth_user_creation.sql` | `docs/database/0010-internal-auth-user-creation.md` | Pending execution |
| 0011 | `supabase/migrations/0011_fix_service_role_claims_and_storage_policies.sql` | `docs/database/0011-service-role-claims-and-storage-policies.md` | Pending execution |
| seed-0001 | `supabase/seeds/0001_demo_freelancer_os_data.sql` | `docs/database/seed-0001-demo-freelancer-os-data.md` | Optional demo seed, pending execution | | seed-0001 | `supabase/seeds/0001_demo_freelancer_os_data.sql` | `docs/database/seed-0001-demo-freelancer-os-data.md` | Optional demo seed, pending execution |
## How To Add The Next Query ## How To Add The Next Query
@@ -38,4 +39,4 @@ Use the migration helper from the repository root:
DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh
``` ```
The helper applies missing queries from `0001` through `0010` in the order listed above and records completed migrations in `neta_internal.schema_migrations`. It uses local `psql` when available, otherwise it runs `psql` through Docker. After migrations, it sends `NOTIFY pgrst, 'reload schema'` so PostgREST can see new RPC functions without a manual restart. The helper applies missing queries from `0001` through `0011` in the order listed above and records completed migrations in `neta_internal.schema_migrations`. It uses local `psql` when available, otherwise it runs `psql` through Docker. After migrations, it sends `NOTIFY pgrst, 'reload schema'` so PostgREST can see new RPC functions without a manual restart.
+4
View File
@@ -96,6 +96,9 @@ existing_objects_cover_migration() {
0010_internal_auth_creation) 0010_internal_auth_creation)
echo "no" echo "no"
;; ;;
0011_service_role_claims_storage)
echo "no"
;;
*) *)
echo "no" echo "no"
;; ;;
@@ -165,6 +168,7 @@ done <<'SQL_FILES'
0008_project_progress_quota|supabase/migrations/0008_add_project_progress_and_quota.sql 0008_project_progress_quota|supabase/migrations/0008_add_project_progress_and_quota.sql
0009_first_admin_registration_lock|supabase/migrations/0009_lock_registration_after_first_admin.sql 0009_first_admin_registration_lock|supabase/migrations/0009_lock_registration_after_first_admin.sql
0010_internal_auth_creation|supabase/migrations/0010_allow_internal_auth_user_creation.sql 0010_internal_auth_creation|supabase/migrations/0010_allow_internal_auth_user_creation.sql
0011_service_role_claims_storage|supabase/migrations/0011_fix_service_role_claims_and_storage_policies.sql
SQL_FILES SQL_FILES
reload_postgrest_schema_cache reload_postgrest_schema_cache
+2 -1
View File
@@ -37,7 +37,8 @@ Do not overwrite already executed SQL without also creating a new ordered migrat
8. `migrations/0008_add_project_progress_and_quota.sql` 8. `migrations/0008_add_project_progress_and_quota.sql`
9. `migrations/0009_lock_registration_after_first_admin.sql` 9. `migrations/0009_lock_registration_after_first_admin.sql`
10. `migrations/0010_allow_internal_auth_user_creation.sql` 10. `migrations/0010_allow_internal_auth_user_creation.sql`
11. Optional local/demo data: `seeds/0001_demo_freelancer_os_data.sql` 11. `migrations/0011_fix_service_role_claims_and_storage_policies.sql`
12. Optional local/demo data: `seeds/0001_demo_freelancer_os_data.sql`
## Apply Migrations ## Apply Migrations
@@ -89,26 +89,38 @@ drop policy if exists "Users can view their own project assets." on storage.obje
create policy "Users can view their own project assets." on storage.objects create policy "Users can view their own project assets." on storage.objects
for select using ( for select using (
bucket_id = 'project-assets' bucket_id = 'project-assets'
and auth.uid()::text = (storage.foldername(name))[1] and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
); );
drop policy if exists "Users can upload their own project assets." on storage.objects; drop policy if exists "Users can upload their own project assets." on storage.objects;
create policy "Users can upload their own project assets." on storage.objects create policy "Users can upload their own project assets." on storage.objects
for insert with check ( for insert with check (
bucket_id = 'project-assets' bucket_id = 'project-assets'
and auth.uid()::text = (storage.foldername(name))[1] and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
); );
drop policy if exists "Users can update their own project assets." on storage.objects; drop policy if exists "Users can update their own project assets." on storage.objects;
create policy "Users can update their own project assets." on storage.objects create policy "Users can update their own project assets." on storage.objects
for update using ( for update using (
bucket_id = 'project-assets' bucket_id = 'project-assets'
and auth.uid()::text = (storage.foldername(name))[1] and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
); );
drop policy if exists "Users can delete their own project assets." on storage.objects; drop policy if exists "Users can delete their own project assets." on storage.objects;
create policy "Users can delete their own project assets." on storage.objects create policy "Users can delete their own project assets." on storage.objects
for delete using ( for delete using (
bucket_id = 'project-assets' bucket_id = 'project-assets'
and auth.uid()::text = (storage.foldername(name))[1] and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
); );
@@ -17,6 +17,23 @@ create index if not exists internal_auth_creations_email_idx
revoke all on schema neta_internal from public; revoke all on schema neta_internal from public;
revoke all on all tables in schema neta_internal from public; revoke all on all tables in schema neta_internal from public;
create or replace function public.neta_current_jwt_role()
returns text
language sql
stable
as $$
select coalesce(
nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''),
nullif(current_setting('request.jwt.claim.role', true), ''),
''
);
$$;
revoke all on function public.neta_current_jwt_role() from public;
grant execute on function public.neta_current_jwt_role() to anon;
grant execute on function public.neta_current_jwt_role() to authenticated;
grant execute on function public.neta_current_jwt_role() to service_role;
create or replace function public.request_internal_auth_creation( create or replace function public.request_internal_auth_creation(
target_email text, target_email text,
target_reason text default 'internal' target_reason text default 'internal'
@@ -27,7 +44,7 @@ security definer
set search_path = public, neta_internal set search_path = public, neta_internal
as $$ as $$
begin begin
if coalesce(current_setting('request.jwt.claim.role', true), '') <> 'service_role' then if public.neta_current_jwt_role() <> 'service_role' then
raise exception 'Only service role can request internal auth creation.'; raise exception 'Only service role can request internal auth creation.';
end if; end if;
@@ -0,0 +1,148 @@
-- 0011: Fix service-role JWT claim handling and storage policies
-- Run after: supabase/migrations/0010_allow_internal_auth_user_creation.sql
create schema if not exists neta_internal;
create table if not exists neta_internal.internal_auth_creations (
id uuid default uuid_generate_v4() primary key,
email text not null,
reason text default 'internal'::text not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
expires_at timestamp with time zone default (timezone('utc'::text, now()) + interval '2 minutes') not null
);
create index if not exists internal_auth_creations_email_idx
on neta_internal.internal_auth_creations (lower(email));
revoke all on schema neta_internal from public;
revoke all on all tables in schema neta_internal from public;
create or replace function public.neta_current_jwt_role()
returns text
language sql
stable
as $$
select coalesce(
nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''),
nullif(current_setting('request.jwt.claim.role', true), ''),
''
);
$$;
revoke all on function public.neta_current_jwt_role() from public;
grant execute on function public.neta_current_jwt_role() to anon;
grant execute on function public.neta_current_jwt_role() to authenticated;
grant execute on function public.neta_current_jwt_role() to service_role;
create or replace function public.request_internal_auth_creation(
target_email text,
target_reason text default 'internal'
)
returns void
language plpgsql
security definer
set search_path = public, neta_internal
as $$
begin
if public.neta_current_jwt_role() <> 'service_role' then
raise exception 'Only service role can request internal auth creation.';
end if;
if target_email is null or btrim(target_email) = '' then
raise exception 'target_email is required.';
end if;
delete from neta_internal.internal_auth_creations
where expires_at <= timezone('utc'::text, now())
or lower(email) = lower(btrim(target_email));
insert into neta_internal.internal_auth_creations (email, reason)
values (btrim(target_email), coalesce(nullif(btrim(target_reason), ''), 'internal'));
end;
$$;
revoke all on function public.request_internal_auth_creation(text, text) from public;
grant execute on function public.request_internal_auth_creation(text, text) to service_role;
drop policy if exists "Avatar images are publicly accessible." on storage.objects;
create policy "Avatar images are publicly accessible."
on storage.objects for select
using (
bucket_id = 'avatars'
or public.neta_current_jwt_role() = 'service_role'
);
drop policy if exists "Users can upload an avatar." on storage.objects;
create policy "Users can upload an avatar."
on storage.objects for insert
with check (
bucket_id = 'avatars'
and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
);
drop policy if exists "Users can update their own avatar." on storage.objects;
create policy "Users can update their own avatar."
on storage.objects for update
using (
bucket_id = 'avatars'
and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
);
drop policy if exists "Users can delete their own avatar." on storage.objects;
create policy "Users can delete their own avatar."
on storage.objects for delete
using (
bucket_id = 'avatars'
and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
);
drop policy if exists "Users can view their own project assets." on storage.objects;
create policy "Users can view their own project assets." on storage.objects
for select using (
bucket_id = 'project-assets'
and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
);
drop policy if exists "Users can upload their own project assets." on storage.objects;
create policy "Users can upload their own project assets." on storage.objects
for insert with check (
bucket_id = 'project-assets'
and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
);
drop policy if exists "Users can update their own project assets." on storage.objects;
create policy "Users can update their own project assets." on storage.objects
for update using (
bucket_id = 'project-assets'
and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
);
drop policy if exists "Users can delete their own project assets." on storage.objects;
create policy "Users can delete their own project assets." on storage.objects
for delete using (
bucket_id = 'project-assets'
and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
);
notify pgrst, 'reload schema';
+34 -5
View File
@@ -193,6 +193,23 @@ revoke all on function public.is_first_admin_setup_available() from public;
grant execute on function public.is_first_admin_setup_available() to anon; grant execute on function public.is_first_admin_setup_available() to anon;
grant execute on function public.is_first_admin_setup_available() to authenticated; grant execute on function public.is_first_admin_setup_available() to authenticated;
create or replace function public.neta_current_jwt_role()
returns text
language sql
stable
as $$
select coalesce(
nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', ''),
nullif(current_setting('request.jwt.claim.role', true), ''),
''
);
$$;
revoke all on function public.neta_current_jwt_role() from public;
grant execute on function public.neta_current_jwt_role() to anon;
grant execute on function public.neta_current_jwt_role() to authenticated;
grant execute on function public.neta_current_jwt_role() to service_role;
create schema if not exists neta_internal; create schema if not exists neta_internal;
create table if not exists neta_internal.internal_auth_creations ( create table if not exists neta_internal.internal_auth_creations (
@@ -219,7 +236,7 @@ security definer
set search_path = public, neta_internal set search_path = public, neta_internal
as $$ as $$
begin begin
if coalesce(current_setting('request.jwt.claim.role', true), '') <> 'service_role' then if public.neta_current_jwt_role() <> 'service_role' then
raise exception 'Only service role can request internal auth creation.'; raise exception 'Only service role can request internal auth creation.';
end if; end if;
@@ -285,14 +302,20 @@ on conflict (id) do nothing;
drop policy if exists "Avatar images are publicly accessible." on storage.objects; drop policy if exists "Avatar images are publicly accessible." on storage.objects;
create policy "Avatar images are publicly accessible." create policy "Avatar images are publicly accessible."
on storage.objects for select on storage.objects for select
using (bucket_id = 'avatars'); using (
bucket_id = 'avatars'
or public.neta_current_jwt_role() = 'service_role'
);
drop policy if exists "Users can upload an avatar." on storage.objects; drop policy if exists "Users can upload an avatar." on storage.objects;
create policy "Users can upload an avatar." create policy "Users can upload an avatar."
on storage.objects for insert on storage.objects for insert
with check ( with check (
bucket_id = 'avatars' bucket_id = 'avatars'
and auth.uid()::text = (storage.foldername(name))[1] and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
); );
drop policy if exists "Users can update their own avatar." on storage.objects; drop policy if exists "Users can update their own avatar." on storage.objects;
@@ -300,7 +323,10 @@ create policy "Users can update their own avatar."
on storage.objects for update on storage.objects for update
using ( using (
bucket_id = 'avatars' bucket_id = 'avatars'
and auth.uid()::text = (storage.foldername(name))[1] and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
); );
drop policy if exists "Users can delete their own avatar." on storage.objects; drop policy if exists "Users can delete their own avatar." on storage.objects;
@@ -308,5 +334,8 @@ create policy "Users can delete their own avatar."
on storage.objects for delete on storage.objects for delete
using ( using (
bucket_id = 'avatars' bucket_id = 'avatars'
and auth.uid()::text = (storage.foldername(name))[1] and (
public.neta_current_jwt_role() = 'service_role'
or auth.uid()::text = (storage.foldername(name))[1]
)
); );