-- 20260729_superadmin_rpc_functions.sql -- -- Postgres SECURITY DEFINER RPC functions callable from the anon role. -- The SuperAdmin no longer holds the service-role key — all SuperAdmin -- writes (push tenant row, push signed license) go through these -- functions, which run with the function owner's privileges and bypass -- RLS. The trust boundary moves from "whoever holds the service-role -- key" to "whoever can present a valid signed license token to -- upsert_license_cloud". -- -- Apply in psql against the Supabase project: -- psql "$SUPABASE_DB_URL" -f sql/20260729_superadmin_rpc_functions.sql -- -- Idempotent — uses CREATE OR REPLACE FUNCTION. CREATE OR REPLACE FUNCTION public.upsert_tenant_cloud(p_tenant jsonb) RETURNS public.tenants LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_row public.tenants; BEGIN INSERT INTO public.tenants AS t ( id, name, code, contact_email, phone, address, status, updated_at ) VALUES ( (p_tenant->>'id')::uuid, p_tenant->>'name', p_tenant->>'code', NULLIF(p_tenant->>'contact_email', ''), NULLIF(p_tenant->>'phone', ''), NULLIF(p_tenant->>'address', ''), COALESCE(NULLIF(p_tenant->>'status', ''), 'active'), NOW() ) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, code = EXCLUDED.code, contact_email = EXCLUDED.contact_email, phone = EXCLUDED.phone, address = EXCLUDED.address, status = EXCLUDED.status, updated_at = NOW() RETURNING * INTO v_row; RETURN v_row; END; $$; CREATE OR REPLACE FUNCTION public.upsert_license_cloud(p_license jsonb) RETURNS public.tenant_licenses LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_row public.tenant_licenses; v_token text; v_payload text; v_tid text; BEGIN -- Trust boundary: a license row is only accepted if its -- `signed_license_token` field is a well-formed JWT whose payload -- `tid` (or `tenant_id`) claim matches the row's `tenant_id`. This -- prevents an anon caller from forging a license for an arbitrary -- tenant. v_token := p_license->>'signed_license_token'; IF v_token IS NULL OR v_token = '' THEN RAISE EXCEPTION 'signed_license_token is required'; END IF; IF (p_license->>'tenant_id') IS NULL THEN RAISE EXCEPTION 'tenant_id is required'; END IF; IF split_part(v_token, '.', 2) = '' THEN RAISE EXCEPTION 'malformed license token'; END IF; v_payload := convert_from( decode( replace(replace(split_part(v_token, '.', 2), '-', '+'), '_', '/'), 'base64' ), 'UTF8' ); v_tid := COALESCE( (v_payload::jsonb->>'tid'), (v_payload::jsonb->>'tenant_id') ); IF v_tid IS DISTINCT FROM (p_license->>'tenant_id') THEN RAISE EXCEPTION 'license token tenant_id mismatch (got %, expected %)', v_tid, p_license->>'tenant_id'; END IF; INSERT INTO public.tenant_licenses AS tl ( id, tenant_id, license_key, signed_license_token, algorithm, term_name, academic_year, start_date, end_date, grace_period_days, max_students, max_staff, status, override_reason, issued_by, updated_at ) VALUES ( (p_license->>'id')::uuid, (p_license->>'tenant_id')::uuid, p_license->>'license_key', p_license->>'signed_license_token', COALESCE(NULLIF(p_license->>'algorithm', ''), 'EdDSA'), p_license->>'term_name', NULLIF(p_license->>'academic_year', ''), (p_license->>'start_date')::date, (p_license->>'end_date')::date, COALESCE((p_license->>'grace_period_days')::int, 7), COALESCE((p_license->>'max_students')::int, 1000), COALESCE((p_license->>'max_staff')::int, 200), COALESCE(NULLIF(p_license->>'status', ''), 'active'), NULLIF(p_license->>'override_reason', ''), COALESCE(NULLIF(p_license->>'issued_by', ''), 'SuperAdmin'), NOW() ) ON CONFLICT (id) DO UPDATE SET license_key = EXCLUDED.license_key, signed_license_token = EXCLUDED.signed_license_token, algorithm = EXCLUDED.algorithm, term_name = EXCLUDED.term_name, academic_year = EXCLUDED.academic_year, start_date = EXCLUDED.start_date, end_date = EXCLUDED.end_date, grace_period_days = EXCLUDED.grace_period_days, max_students = EXCLUDED.max_students, max_staff = EXCLUDED.max_staff, status = EXCLUDED.status, override_reason = EXCLUDED.override_reason, issued_by = EXCLUDED.issued_by, updated_at = NOW() RETURNING * INTO v_row; RETURN v_row; END; $$; -- The anon role needs EXECUTE on the new functions; the function -- bodies are SECURITY DEFINER so they run as the function owner and -- bypass RLS for the writes. GRANT EXECUTE ON FUNCTION public.upsert_tenant_cloud(jsonb) TO anon, authenticated; GRANT EXECUTE ON FUNCTION public.upsert_license_cloud(jsonb) TO anon, authenticated;