Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e064981
fix(db): block org customer_id writes without org.update_billing
cursoragent Aug 26, 2026
7f38013
test: fix org billing guard user id typing
cursoragent Aug 26, 2026
8082e60
test: isolate org billing guard fixtures from creator bootstrap
cursoragent Aug 26, 2026
8618d1a
test: strengthen org billing guard pgTAP assertions
cursoragent Aug 26, 2026
3268af6
ci: retrigger workflow after Cloudflare shard flake
cursoragent Aug 26, 2026
b34457c
fix(db): address CodeRabbit review on org billing guard
cursoragent Aug 26, 2026
20a1464
fix(db): wrap org billing guard migration comments for SQLFluff LT05
cursoragent Aug 26, 2026
622fa84
fix(db): re-stamp org billing guard migration after main advance
cursoragent Aug 26, 2026
ed4a7b0
Merge branch 'main' into cursor/org-billing-column-guard-9734
TorichanCapgo Aug 26, 2026
cfee12d
fix(db): make org customer_id service-managed only
cursoragent Aug 26, 2026
5433a86
test(db): fix org customer_id pgTAP bootstrap assertions
cursoragent Aug 26, 2026
d113950
merge: sync main into org customer_id guard branch
cursoragent Aug 26, 2026
faa4b42
fix(org): keep JWT org create on auth path for audit and MFA
cursoragent Aug 26, 2026
b1895bf
test(org): delete orgs before pending stripe cleanup
cursoragent Aug 26, 2026
4c1635c
fix(db): re-stamp org customer_id guard migration after main
cursoragent Aug 26, 2026
bc8ae44
ci: retrigger after flaky stats plugin test
cursoragent Aug 26, 2026
302f199
ci: rerun full test suite
cursoragent Aug 26, 2026
7c8dda9
docs(org): note customer_id bootstrap on JWT org create
cursoragent Aug 26, 2026
dfe306e
fix(org): reload org row before Stripe bootstrap on create
cursoragent Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions supabase/functions/_backend/public/organization/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Comment thread
cursor[bot] marked this conversation as resolved.
const { error: errorOrg } = await supabaseWithAuth(c, auth)
.from('orgs')
.insert({
...newOrg,
onboarding,
id,
name,
created_by,
management_email,
website: orgWebsite,
onboarding: orgOnboarding,
})

if (errorOrg) {
Expand Down
69 changes: 44 additions & 25 deletions supabase/functions/_backend/triggers/on_organization_create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,49 @@ import { backgroundTask } from '../utils/utils.ts'
export const app = new Hono<MiddlewareKeyVariables>()

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(),
Expand All @@ -49,39 +68,39 @@ 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: {
cron: '* * * * *',
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)
Expand Down
166 changes: 166 additions & 0 deletions supabase/migrations/20260826103000_org_billing_column_guard.sql
Original file line number Diff line number Diff line change
@@ -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;
$$;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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";
Loading
Loading