diff --git a/supabase/functions/_backend/public/organization/post.ts b/supabase/functions/_backend/public/organization/post.ts index 0524c84e98..46614b69b6 100644 --- a/supabase/functions/_backend/public/organization/post.ts +++ b/supabase/functions/_backend/public/organization/post.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { safeParseSchema } from '../../utils/schema_validation.ts' import { quickError, simpleError } from '../../utils/hono.ts' import { closeClient, getPgClient } from '../../utils/pg.ts' +import { assertJwtMfaAssurance } from '../../utils/jwt_mfa_assurance.ts' import { supabaseAdmin, supabaseWithAuth } from '../../utils/supabase.ts' import { parseOrgOnboardingIntent } from '../../utils/org_onboarding_intent.ts' import { normalizeWebsiteUrl } from './website.ts' @@ -193,12 +194,11 @@ async function insertOrgForApiKey( name, created_by, management_email, - customer_id, website, onboarding ) - VALUES ($1::uuid, $2::varchar, $3::uuid, $4::varchar, $5::varchar, $6::varchar, $7::jsonb)`, - [org.id, org.name, org.created_by, org.management_email, org.customer_id, org.website, JSON.stringify(org.onboarding)], + VALUES ($1::uuid, $2::varchar, $3::uuid, $4::varchar, $5::varchar, $6::jsonb)`, + [org.id, org.name, org.created_by, org.management_email, org.website, JSON.stringify(org.onboarding)], ) await dbClient.query( @@ -290,11 +290,19 @@ export async function post( await insertOrgForApiKey(c, auth, newOrg) } else { + await assertJwtMfaAssurance(c, auth) + + // Omit customer_id: org-create trigger links pre-created pending stripe_info. + const { id, name, created_by, management_email, website: orgWebsite, onboarding: orgOnboarding } = newOrg const { error: errorOrg } = await supabaseWithAuth(c, auth) .from('orgs') .insert({ - ...newOrg, - onboarding, + id, + name, + created_by, + management_email, + website: orgWebsite, + onboarding: orgOnboarding, }) if (errorOrg) { diff --git a/supabase/functions/_backend/triggers/on_organization_create.ts b/supabase/functions/_backend/triggers/on_organization_create.ts index 21ba5afc35..4053325b96 100644 --- a/supabase/functions/_backend/triggers/on_organization_create.ts +++ b/supabase/functions/_backend/triggers/on_organization_create.ts @@ -15,30 +15,49 @@ import { backgroundTask } from '../utils/utils.ts' export const app = new Hono() app.post('/', middlewareAPISecret, triggerValidator('orgs', 'INSERT'), async (c) => { - const record = c.get('webhookBody') as Database['public']['Tables']['orgs']['Row'] - cloudlog({ requestId: c.get('requestId'), message: 'record', record }) + const queuedRecord = c.get('webhookBody') as Database['public']['Tables']['orgs']['Row'] + cloudlog({ requestId: c.get('requestId'), message: 'record', record: queuedRecord }) - if (!record.id) { + if (!queuedRecord.id) { cloudlog({ requestId: c.get('requestId'), message: 'No id' }) - throw simpleError('no_id', 'No id', { record }) + throw simpleError('no_id', 'No id', { record: queuedRecord }) } + // INSERT queue payloads omit customer_id when org-create assigns it in a later + // AFTER INSERT trigger; reload the committed row before Stripe bootstrap. + const { data: orgRow, error: orgLoadError } = await supabaseAdmin(c) + .from('orgs') + .select('*') + .eq('id', queuedRecord.id) + .single() + + if (orgLoadError || !orgRow) { + cloudlog({ + requestId: c.get('requestId'), + message: 'org create reload failed, using queue payload', + orgId: queuedRecord.id, + error: orgLoadError?.message, + }) + } + + const org = orgRow ?? queuedRecord + let trialPlanName: string | null | undefined - if (!record.customer_id) { - trialPlanName = await createStripeCustomer(c, record) + if (!org.customer_id) { + trialPlanName = await createStripeCustomer(c, org) } - else if (record.customer_id.startsWith('pending_')) { - trialPlanName = await finalizePendingStripeCustomer(c, record) + else if (org.customer_id.startsWith('pending_')) { + trialPlanName = await finalizePendingStripeCustomer(c, org) } if (trialPlanName) { const { data: creator, error: creatorError } = await supabaseAdmin(c) .from('users') .select('email') - .eq('id', record.created_by) + .eq('id', org.created_by) .maybeSingle() if (creatorError) - cloudlog({ requestId: c.get('requestId'), message: 'trial plan Bento creator lookup failed', userId: record.created_by, error: creatorError }) + cloudlog({ requestId: c.get('requestId'), message: 'trial plan Bento creator lookup failed', userId: org.created_by, error: creatorError }) if (creator?.email) { await backgroundTask(c, syncBentoSubscriberTags(c, { email: creator.email.trim().toLowerCase(), @@ -49,25 +68,25 @@ app.post('/', middlewareAPISecret, triggerValidator('orgs', 'INSERT'), async (c) await backgroundTask(c, groupIdentifyPosthog(c, { groupType: 'organization', - groupKey: record.id, + groupKey: org.id, properties: { - name: record.name, - management_email: record.management_email, - customer_id: record.customer_id, - created_by: record.created_by, - created_at: record.created_at, - website: record.website, + name: org.name, + management_email: org.management_email, + customer_id: org.customer_id, + created_by: org.created_by, + created_at: org.created_at, + website: org.website, }, })) - const onboardingIntent = parseOrgOnboardingIntent(record.onboarding) + const onboardingIntent = parseOrgOnboardingIntent(org.onboarding) const onboardingBentoData = buildOnboardingIntentBentoEventData(c, onboardingIntent, { - id: record.id, - name: record.name, - website: record.website, + id: org.id, + name: org.name, + website: org.website, }) - await syncOrgOnboardingIntentForOrg(c, record) + await syncOrgOnboardingIntentForOrg(c, org) await sendEventToTracking(c, { bento: { @@ -75,13 +94,13 @@ app.post('/', middlewareAPISecret, triggerValidator('orgs', 'INSERT'), async (c) data: onboardingBentoData, event: 'org:created', preferenceKey: 'onboarding', - uniqId: `org:created:${record.id}`, + uniqId: `org:created:${org.id}`, }, channel: 'org-created', event: 'Org Created', sentToBento: true, - user_id: record.id, - groups: { organization: record.id }, + user_id: org.id, + groups: { organization: org.id }, }) return c.json(BRES) diff --git a/supabase/migrations/20260826103000_org_billing_column_guard.sql b/supabase/migrations/20260826103000_org_billing_column_guard.sql new file mode 100644 index 0000000000..f476ca4364 --- /dev/null +++ b/supabase/migrations/20260826103000_org_billing_column_guard.sql @@ -0,0 +1,166 @@ +-- Block user-context writes to orgs.customer_id. Only service_role/postgres +-- (is_internal_request_role) and the org-create bootstrap path may set it. +-- +-- Execution profile for guard_org_billing_columns (BEFORE INSERT OR UPDATE OF +-- customer_id): +-- - Frequency: once per org row when customer_id is supplied on INSERT or +-- changes on UPDATE. Console-scale, not plugin hot path. +-- - Roles: authenticated and anon (capgkey) via PostgREST are always denied +-- unless org-create bootstrap GUC matches the row during AFTER INSERT setup. +-- service_role/postgres bypass via is_internal_request_role(). +-- - Cardinality: single-row trigger; no table scans. + +CREATE OR REPLACE FUNCTION "public"."guard_org_billing_columns"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_request_role text := public.current_request_role(); + v_bootstrap_org_id text := pg_catalog.current_setting('capgo.org_creation_bootstrap_org_id', true); +BEGIN + IF public.is_internal_request_role(v_request_role) THEN + RETURN NEW; + END IF; + + IF TG_OP = 'UPDATE' + AND NEW.customer_id IS DISTINCT FROM OLD.customer_id + AND v_bootstrap_org_id <> '' + AND v_bootstrap_org_id = NEW.id::text + THEN + RETURN NEW; + END IF; + + IF TG_OP = 'INSERT' AND NEW.customer_id IS NOT NULL THEN + RAISE EXCEPTION 'PERMISSION_DENIED_ORG_CUSTOMER_ID' + USING ERRCODE = '42501'; + END IF; + + IF TG_OP = 'UPDATE' AND NEW.customer_id IS DISTINCT FROM OLD.customer_id THEN + RAISE EXCEPTION 'PERMISSION_DENIED_ORG_CUSTOMER_ID' + USING ERRCODE = '42501'; + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."guard_org_billing_columns"() OWNER TO "postgres"; + +REVOKE ALL ON FUNCTION "public"."guard_org_billing_columns"() FROM PUBLIC; + +GRANT ALL ON FUNCTION "public"."guard_org_billing_columns"() TO "service_role"; + +COMMENT ON FUNCTION "public"."guard_org_billing_columns"() IS + 'BEFORE INSERT/UPDATE OF customer_id guard. User/capgkey roles cannot write customer_id; ' + 'service_role/postgres bypass via is_internal_request_role. Org-create bootstrap may set ' + 'pending customer_id while capgo.org_creation_bootstrap_org_id matches the row id.'; + +DROP TRIGGER IF EXISTS "guard_org_billing_columns" ON "public"."orgs"; +DROP TRIGGER IF EXISTS "guard_org_billing_columns_insert" ON "public"."orgs"; + +CREATE TRIGGER "guard_org_billing_columns_insert" + BEFORE INSERT ON "public"."orgs" + FOR EACH ROW + EXECUTE FUNCTION "public"."guard_org_billing_columns"(); + +CREATE TRIGGER "guard_org_billing_columns" + BEFORE UPDATE OF "customer_id" ON "public"."orgs" + FOR EACH ROW + EXECUTE FUNCTION "public"."guard_org_billing_columns"(); + +-- Keep bootstrap GUC active through pending customer_id assignment so the guard +-- allows generate_org_user_stripe_info_on_org_create to finish for user inserts +-- that omit customer_id (legacy/direct PostgREST path). +CREATE OR REPLACE FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + solo_plan_stripe_id varchar; + pending_customer_id varchar; + trial_at_date timestamptz; + org_super_admin_role_id uuid; +BEGIN + PERFORM set_config('capgo.org_creation_bootstrap_org_id', NEW.id::text, true); + + INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) + VALUES (NEW.created_by, NEW.id, public.rbac_role_org_super_admin(), false); + + SELECT id INTO org_super_admin_role_id + FROM public.roles + WHERE name = public.rbac_role_org_super_admin() + AND scope_type = public.rbac_scope_org() + LIMIT 1; + + IF org_super_admin_role_id IS NOT NULL THEN + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), NEW.created_by, org_super_admin_role_id, public.rbac_scope_org(), NEW.id, + NEW.created_by, now(), 'Organization creator', true + ) ON CONFLICT DO NOTHING; + END IF; + + IF NEW.customer_id IS NOT NULL THEN + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + RETURN NEW; + END IF; + + pending_customer_id := 'pending_' || NEW.id::text; + + IF EXISTS ( + SELECT 1 + FROM public.stripe_info + WHERE customer_id = pending_customer_id + ) THEN + UPDATE public.orgs + SET customer_id = pending_customer_id + WHERE id = NEW.id; + + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + RETURN NEW; + END IF; + + SELECT stripe_id INTO solo_plan_stripe_id + FROM public.plans + WHERE name = 'Solo' + LIMIT 1; + + IF solo_plan_stripe_id IS NULL THEN + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + RAISE WARNING 'Solo plan not found, skipping sync stripe_info creation for org %', NEW.id; + RETURN NEW; + END IF; + + trial_at_date := NOW() + INTERVAL '15 days'; + + INSERT INTO public.stripe_info ( + customer_id, + product_id, + trial_at, + status, + is_good_plan + ) VALUES ( + pending_customer_id, + solo_plan_stripe_id, + trial_at_date, + NULL, + true + ); + + UPDATE public.orgs + SET customer_id = pending_customer_id + WHERE id = NEW.id; + + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() OWNER TO "postgres"; + +REVOKE ALL ON FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() FROM PUBLIC; + +GRANT ALL ON FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() TO "service_role"; diff --git a/supabase/tests/73_test_org_billing_column_guard.sql b/supabase/tests/73_test_org_billing_column_guard.sql new file mode 100644 index 0000000000..272f34260e --- /dev/null +++ b/supabase/tests/73_test_org_billing_column_guard.sql @@ -0,0 +1,177 @@ +-- org.customer_id is service-managed: user/capgkey roles cannot write it. +BEGIN; + +SELECT plan(7); + +SELECT tests.authenticate_as_service_role(); +SELECT tests.create_supabase_user('org_billing_guard_admin', 'org_billing_guard_admin@test.local'); +SELECT tests.create_supabase_user('org_billing_guard_super', 'org_billing_guard_super@test.local'); + +INSERT INTO public.users (id, email, created_at, updated_at) +VALUES + (tests.get_supabase_uid('org_billing_guard_admin'), 'org_billing_guard_admin@test.local', NOW(), NOW()), + (tests.get_supabase_uid('org_billing_guard_super'), 'org_billing_guard_super@test.local', NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.stripe_info ( + customer_id, + status, + product_id, + subscription_id, + trial_at, + is_good_plan +) +VALUES + ( + 'cus_org_billing_guard_730001', + 'succeeded', + 'prod_LQIregjtNduh4q', + 'sub_org_billing_guard_730001', + NOW() + INTERVAL '15 days', + true + ), + ( + 'cus_org_billing_guard_730002', + 'succeeded', + 'prod_LQIregjtNduh4q', + 'sub_org_billing_guard_730002', + NOW() + INTERVAL '15 days', + true + ) +ON CONFLICT (customer_id) DO NOTHING; + +INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) +VALUES ( + '73000000-0000-4000-8000-000000000073', + tests.get_supabase_uid('org_billing_guard_super'), + 'Org billing column guard', + 'org-billing-guard@test.local', + 'cus_org_billing_guard_730001' +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + granted_by, + reason, + is_direct +) +SELECT + public.rbac_principal_user(), + members.user_id, + roles.id, + public.rbac_scope_org(), + '73000000-0000-4000-8000-000000000073'::uuid, + tests.get_supabase_uid('org_billing_guard_admin'), + 'pgTAP org customer_id guard fixture', + true +FROM ( + VALUES + (tests.get_supabase_uid('org_billing_guard_admin'), public.rbac_role_org_admin()), + (tests.get_supabase_uid('org_billing_guard_super'), public.rbac_role_org_super_admin()) +) AS members(user_id, role_name) +CROSS JOIN public.roles AS roles +WHERE roles.name = members.role_name + AND roles.scope_type = public.rbac_scope_org() +ON CONFLICT DO NOTHING; + +SELECT tests.authenticate_as('org_billing_guard_admin'); + +SELECT throws_ok( + $$ + UPDATE public.orgs + SET customer_id = NULL + WHERE id = '73000000-0000-4000-8000-000000000073'::uuid + $$, + '42501', + 'PERMISSION_DENIED_ORG_CUSTOMER_ID', + 'org_admin cannot mutate customer_id' +); + +SELECT lives_ok( + $$ + UPDATE public.orgs + SET name = 'Org billing column guard updated' + WHERE id = '73000000-0000-4000-8000-000000000073'::uuid + $$, + 'org_admin can still update org settings columns' +); + +SELECT tests.authenticate_as('org_billing_guard_super'); + +SELECT throws_ok( + $$ + UPDATE public.orgs + SET customer_id = 'cus_org_billing_guard_730002' + WHERE id = '73000000-0000-4000-8000-000000000073'::uuid + $$, + '42501', + 'PERMISSION_DENIED_ORG_CUSTOMER_ID', + 'org_super_admin cannot mutate customer_id' +); + +SELECT tests.authenticate_as_service_role(); +SET LOCAL ROLE service_role; +SET LOCAL "request.jwt.claim.role" = 'service_role'; + +SELECT lives_ok( + $$ + UPDATE public.orgs + SET customer_id = 'cus_org_billing_guard_730002' + WHERE id = '73000000-0000-4000-8000-000000000073'::uuid + $$, + 'service_role can update org customer_id' +); + +SELECT tests.authenticate_as('org_billing_guard_super'); + +SELECT throws_ok( + $$ + INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) + VALUES ( + '73000000-0000-4000-8000-000000000075'::uuid, + tests.get_supabase_uid('org_billing_guard_super'), + 'Org customer_id insert blocked', + 'org-customer-id-insert-blocked@test.local', + 'cus_org_billing_guard_evil' + ) + $$, + '42501', + 'PERMISSION_DENIED_ORG_CUSTOMER_ID', + 'user cannot insert org with customer_id' +); + +SELECT tests.authenticate_as_service_role(); +SET LOCAL ROLE service_role; +SET LOCAL "request.jwt.claim.role" = 'service_role'; + +SELECT lives_ok( + $$ + INSERT INTO public.orgs (id, created_by, name, management_email) + VALUES ( + '73000000-0000-4000-8000-000000000074'::uuid, + tests.get_supabase_uid('org_billing_guard_super'), + 'Org customer_id bootstrap', + 'org-customer-id-bootstrap@test.local' + ) + ON CONFLICT (id) DO NOTHING + $$, + 'service_role can insert org without customer_id' +); + +SELECT is( + (SELECT customer_id FROM public.orgs WHERE id = '73000000-0000-4000-8000-000000000074'::uuid), + 'pending_73000000-0000-4000-8000-000000000074', + 'org create bootstrap assigns pending customer_id' +); + +SELECT tests.clear_authentication(); +SELECT set_config('request.headers', '{}', true); + +SELECT * FROM finish(); + +ROLLBACK; diff --git a/tests/org-billing-column-guard.test.ts b/tests/org-billing-column-guard.test.ts new file mode 100644 index 0000000000..912c2c57d5 --- /dev/null +++ b/tests/org-billing-column-guard.test.ts @@ -0,0 +1,247 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + executeSQL, + fetchTestRequest, + getAuthHeadersForCredentials, + getEndpointUrl, + getSupabaseClient, + SUPABASE_ANON_KEY, + SUPABASE_BASE_URL, +} from './test-utils.ts' + +if (!SUPABASE_BASE_URL) + throw new Error('SUPABASE_URL is required for org customer_id guard tests') +if (!SUPABASE_ANON_KEY) + throw new Error('SUPABASE_ANON_KEY is required for org customer_id guard tests') + +const serviceRoleSupabase = getSupabaseClient() + +const fixtureId = randomUUID() +const orgId = randomUUID() +let settingsAdminUserId: string +let superAdminUserId: string +const settingsAdminEmail = `org-customer-id-guard-admin-${fixtureId}@capgo.test` +const superAdminEmail = `org-customer-id-guard-super-${fixtureId}@capgo.test` +const testPassword = `Capgo!${fixtureId}` +const originalCustomerId = `cus_org_customer_id_guard_${fixtureId.replaceAll('-', '').slice(0, 16)}` +const replacementCustomerId = `cus_org_customer_id_guard_alt_${fixtureId.replaceAll('-', '').slice(0, 12)}` + +async function bindOrgRole(userId: string, roleName: string) { + const [role] = await executeSQL( + `SELECT id FROM public.roles WHERE name = $1 AND scope_type = public.rbac_scope_org() LIMIT 1`, + [roleName], + ) + if (!role?.id) + throw new Error(`Unable to resolve org role ${roleName}`) + + await executeSQL( + `INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), $1::uuid, $2::uuid, public.rbac_scope_org(), $3::uuid, + $1::uuid, 'org customer_id guard fixture', true + ) + ON CONFLICT DO NOTHING`, + [userId, role.id, orgId], + ) +} + +function withRestHeaders(headers: Record) { + return { + ...headers, + 'apikey': SUPABASE_ANON_KEY, + 'Content-Type': 'application/json', + } +} + +async function patchOrg(headers: Record, body: Record) { + const response = await fetchTestRequest(getEndpointUrl(`/rest/v1/orgs?id=eq.${orgId}`), { + method: 'PATCH', + headers: { + ...withRestHeaders(headers), + Prefer: 'return=representation', + }, + body: JSON.stringify(body), + }) + const text = await response.text() + const data = text ? JSON.parse(text) as unknown : null + return { response, data } +} + +describe('org customer_id guard', () => { + let settingsAdminHeaders: Record + let superAdminHeaders: Record + const createdOrgIds: string[] = [] + + beforeAll(async () => { + const { data: settingsAdminAuth, error: settingsAdminAuthError } = await serviceRoleSupabase.auth.admin.createUser({ + email: settingsAdminEmail, + password: testPassword, + email_confirm: true, + }) + if (settingsAdminAuthError) + throw settingsAdminAuthError + + const { data: superAdminAuth, error: superAdminAuthError } = await serviceRoleSupabase.auth.admin.createUser({ + email: superAdminEmail, + password: testPassword, + email_confirm: true, + }) + if (superAdminAuthError) + throw superAdminAuthError + + await executeSQL( + `INSERT INTO public.users (id, email, first_name) + VALUES ($1::uuid, $2, 'Org Customer ID Guard Admin'), ($3::uuid, $4, 'Org Customer ID Guard Super') + ON CONFLICT (id) DO NOTHING`, + [settingsAdminAuth.user.id, settingsAdminEmail, superAdminAuth.user.id, superAdminEmail], + ) + + settingsAdminUserId = settingsAdminAuth.user.id + superAdminUserId = superAdminAuth.user.id + + await executeSQL( + `INSERT INTO public.stripe_info ( + customer_id, status, product_id, subscription_id, trial_at, is_good_plan + ) VALUES ($1, 'succeeded', 'prod_LQIregjtNduh4q', $2, NOW() + INTERVAL '15 days', true), + ($3, 'succeeded', 'prod_LQIregjtNduh4q', $4, NOW() + INTERVAL '15 days', true)`, + [originalCustomerId, `sub_${fixtureId}`, replacementCustomerId, `sub_alt_${fixtureId}`], + ) + + await executeSQL( + `INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) + VALUES ($1::uuid, $2::uuid, $3, $4, $5)`, + [orgId, superAdminUserId, `Org customer_id guard ${fixtureId}`, settingsAdminEmail, originalCustomerId], + ) + + await executeSQL( + `INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) + VALUES ($1::uuid, $2::uuid, public.rbac_role_org_admin(), false) + ON CONFLICT DO NOTHING`, + [settingsAdminUserId, orgId], + ) + + await bindOrgRole(settingsAdminUserId, 'org_admin') + await bindOrgRole(superAdminUserId, 'org_super_admin') + + settingsAdminHeaders = await getAuthHeadersForCredentials(settingsAdminEmail, testPassword) + superAdminHeaders = await getAuthHeadersForCredentials(superAdminEmail, testPassword) + }) + + afterAll(async () => { + if (createdOrgIds.length > 0) { + await executeSQL('DELETE FROM public.orgs WHERE id = ANY($1::uuid[])', [createdOrgIds]) + await executeSQL( + 'DELETE FROM public.stripe_info WHERE customer_id = ANY($1::text[])', + [createdOrgIds.map(id => `pending_${id}`)], + ) + } + await executeSQL('DELETE FROM public.orgs WHERE id = $1::uuid', [orgId]) + await executeSQL( + 'DELETE FROM public.stripe_info WHERE customer_id = ANY($1::text[])', + [[originalCustomerId, replacementCustomerId]], + ) + await serviceRoleSupabase.auth.admin.deleteUser(settingsAdminUserId) + await serviceRoleSupabase.auth.admin.deleteUser(superAdminUserId) + }) + + it('blocks org_admin from changing customer_id via PostgREST', async () => { + const { response, data } = await patchOrg(settingsAdminHeaders, { customer_id: null }) + + expect(response.status).toBe(403) + expect(data).toMatchObject({ + code: '42501', + message: 'PERMISSION_DENIED_ORG_CUSTOMER_ID', + }) + + const { data: orgRow, error: readError } = await serviceRoleSupabase + .from('orgs') + .select('customer_id') + .eq('id', orgId) + .single() + + expect(readError).toBeNull() + expect(orgRow?.customer_id).toBe(originalCustomerId) + }) + + it('blocks org_super_admin from changing customer_id via PostgREST', async () => { + const { response, data } = await patchOrg(superAdminHeaders, { customer_id: replacementCustomerId }) + + expect(response.status).toBe(403) + expect(data).toMatchObject({ + code: '42501', + message: 'PERMISSION_DENIED_ORG_CUSTOMER_ID', + }) + }) + + it('still allows org settings principals to change cosmetic org fields', async () => { + const updatedName = `Org customer_id guard renamed ${fixtureId}` + + const { response, data } = await patchOrg(settingsAdminHeaders, { name: updatedName }) + + expect(response.status).toBe(200) + expect(Array.isArray(data)).toBe(true) + + const { data: orgRow, error: readError } = await serviceRoleSupabase + .from('orgs') + .select('name') + .eq('id', orgId) + .single() + + expect(readError).toBeNull() + expect(orgRow?.name).toBe(updatedName) + }) + + it('allows service_role to change customer_id via PostgREST', async () => { + const { error } = await serviceRoleSupabase + .from('orgs') + .update({ customer_id: replacementCustomerId }) + .eq('id', orgId) + + expect(error).toBeNull() + + const { data: orgRow, error: readError } = await serviceRoleSupabase + .from('orgs') + .select('customer_id') + .eq('id', orgId) + .single() + + expect(readError).toBeNull() + expect(orgRow?.customer_id).toBe(replacementCustomerId) + }) + + it('assigns pending customer_id when creating an org via the organization API', async () => { + const orgName = `Org customer_id guard create ${fixtureId}` + + const response = await fetchTestRequest(getEndpointUrl('/organization'), { + method: 'POST', + headers: { + ...settingsAdminHeaders, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: orgName, + email: settingsAdminEmail, + estimatedMau: 1000, + intent: 'ota', + }), + }) + + expect(response.status).toBe(200) + const payload = await response.json() as { id?: string } + expect(payload.id).toBeTruthy() + createdOrgIds.push(payload.id!) + + const { data: orgRow, error: readError } = await serviceRoleSupabase + .from('orgs') + .select('customer_id, name') + .eq('id', payload.id!) + .single() + + expect(readError).toBeNull() + expect(orgRow?.name).toBe(orgName) + expect(orgRow?.customer_id).toBe(`pending_${payload.id}`) + }) +}) diff --git a/tests/test-utils.ts b/tests/test-utils.ts index b6851bb401..d6043b51fb 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -136,6 +136,10 @@ export const SUPABASE_ANON_KEY = env.SUPABASE_ANON_KEY ?? '' * All other endpoints go to the API worker */ export function getEndpointUrl(path: string): string { + // PostgREST is always served from the Supabase API host (also under Cloudflare CI). + if (path.startsWith('/rest/')) + return `${SUPABASE_BASE_URL}${path}` + if (!USE_CLOUDFLARE) { // In CI, Node/Undici prefers IPv6 for localhost (::1). Supabase Edge runtime // is bound to IPv4 (127.0.0.1) in the workflow, so normalize to IPv4.