From da8c55d1ad85edd022a49c99f3dd86e68047496c Mon Sep 17 00:00:00 2001 From: Poyraz Avsever Date: Sun, 14 Jun 2026 12:52:11 +0300 Subject: [PATCH] feat: enhance service role handling and storage policies in migrations and schemas --- docker-compose.dokploy.yml | 5 +- docker-compose.full.yml | 5 +- ...ervice-role-claims-and-storage-policies.md | 18 +++ docs/database/query-order.md | 3 +- scripts/apply-migrations.sh | 4 + supabase/README.md | 3 +- .../0003_add_project_planning_assets.sql | 20 ++- ...0010_allow_internal_auth_user_creation.sql | 19 ++- ...rvice_role_claims_and_storage_policies.sql | 148 ++++++++++++++++++ supabase/schema.sql | 39 ++++- 10 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 docs/database/0011-service-role-claims-and-storage-policies.md create mode 100644 supabase/migrations/0011_fix_service_role_claims_and_storage_policies.sql diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index 2db3cec..1d20cfa 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -96,6 +96,9 @@ services: SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in Dokploy env} POSTGREST_URL: http://neta-rest:3000 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 FILE_SIZE_LIMIT: ${STORAGE_FILE_SIZE_LIMIT:-52428800} STORAGE_BACKEND: file @@ -115,7 +118,7 @@ services: neta-storage: condition: service_started 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 NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2} volumes: diff --git a/docker-compose.full.yml b/docker-compose.full.yml index 142a28c..2c65c5d 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -101,6 +101,9 @@ services: SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY:?Set SUPABASE_SERVICE_ROLE_KEY in .env} POSTGREST_URL: http://neta-rest:3000 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 FILE_SIZE_LIMIT: ${STORAGE_FILE_SIZE_LIMIT:-52428800} STORAGE_BACKEND: file @@ -121,7 +124,7 @@ services: neta-storage: condition: service_started 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 NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2} volumes: diff --git a/docs/database/0011-service-role-claims-and-storage-policies.md b/docs/database/0011-service-role-claims-and-storage-policies.md new file mode 100644 index 0000000..6778c32 --- /dev/null +++ b/docs/database/0011-service-role-claims-and-storage-policies.md @@ -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. diff --git a/docs/database/query-order.md b/docs/database/query-order.md index d8698c7..c4275b6 100644 --- a/docs/database/query-order.md +++ b/docs/database/query-order.md @@ -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 | | 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 | +| 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 | ## 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 ``` -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. diff --git a/scripts/apply-migrations.sh b/scripts/apply-migrations.sh index bbc2f60..5c7973b 100644 --- a/scripts/apply-migrations.sh +++ b/scripts/apply-migrations.sh @@ -96,6 +96,9 @@ existing_objects_cover_migration() { 0010_internal_auth_creation) echo "no" ;; + 0011_service_role_claims_storage) + echo "no" + ;; *) echo "no" ;; @@ -165,6 +168,7 @@ done <<'SQL_FILES' 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 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 reload_postgrest_schema_cache diff --git a/supabase/README.md b/supabase/README.md index 405dfe2..e161d32 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -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` 9. `migrations/0009_lock_registration_after_first_admin.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 diff --git a/supabase/migrations/0003_add_project_planning_assets.sql b/supabase/migrations/0003_add_project_planning_assets.sql index e1891f5..b437769 100644 --- a/supabase/migrations/0003_add_project_planning_assets.sql +++ b/supabase/migrations/0003_add_project_planning_assets.sql @@ -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 for select using ( 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; create policy "Users can upload their own project assets." on storage.objects for insert with check ( 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; create policy "Users can update their own project assets." on storage.objects for update using ( 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; create policy "Users can delete their own project assets." on storage.objects for delete using ( 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] + ) ); diff --git a/supabase/migrations/0010_allow_internal_auth_user_creation.sql b/supabase/migrations/0010_allow_internal_auth_user_creation.sql index 2663c3e..cfa6d4b 100644 --- a/supabase/migrations/0010_allow_internal_auth_user_creation.sql +++ b/supabase/migrations/0010_allow_internal_auth_user_creation.sql @@ -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 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' @@ -27,7 +44,7 @@ security definer set search_path = public, neta_internal as $$ 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.'; end if; diff --git a/supabase/migrations/0011_fix_service_role_claims_and_storage_policies.sql b/supabase/migrations/0011_fix_service_role_claims_and_storage_policies.sql new file mode 100644 index 0000000..6715624 --- /dev/null +++ b/supabase/migrations/0011_fix_service_role_claims_and_storage_policies.sql @@ -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'; diff --git a/supabase/schema.sql b/supabase/schema.sql index 51ab6d6..77ee666 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -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 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 table if not exists neta_internal.internal_auth_creations ( @@ -219,7 +236,7 @@ security definer set search_path = public, neta_internal as $$ 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.'; end if; @@ -285,14 +302,20 @@ on conflict (id) do nothing; 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'); + 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 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; @@ -300,7 +323,10 @@ create policy "Users can update their own avatar." on storage.objects for update using ( 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; @@ -308,5 +334,8 @@ create policy "Users can delete their own avatar." on storage.objects for delete using ( 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] + ) );