From 41ecb1dd8e76bbe81d9e03128dccfe23f6a2daa1 Mon Sep 17 00:00:00 2001 From: Joseph Shearer Date: Fri, 22 May 2026 13:08:38 -0400 Subject: [PATCH] billing: split `invoices_ext` data view from authorization wrapper `public.invoices_ext`'s `authorized_tenants` CTE materializes the full `tenants` table for any role with `rolbypassrls`, and its LATERAL preview branch runs `generate_series` and `billing_report_202308` across every materialized tenant. The GraphQL resolver's parameterized `WHERE billed_prefix = $1` did not push through the CTE, so `tenant.invoices` hangs. Extract the data logic into `internal.invoices_ext` (no auth) and query it directly from the resolver, which already runs `verify_authorization`: * `billed_prefix = $1` pushes through `UNION ALL` into each branch's tenant predicate; preview's `tenants` scan becomes a single-row index lookup. * `invoiceType` filters cause the planner to prune the preview and manual branches entirely. `public.invoices_ext` is rewritten as a thin join over the internal view; `billing-integrations/publish.rs` and the pgTAP suite behave identically. --- ...5a0ce197cb8786ba88c6584903ed9e9c02f4.json} | 4 +- ...2377d49cac6936b8ae89d74dd7268adfa7e2.json} | 4 +- crates/control-plane-api/src/billing/db.rs | 4 +- ...60522120000_billing_invoices_ext_split.sql | 86 +++++++++++++++++++ 4 files changed, 92 insertions(+), 6 deletions(-) rename .sqlx/{query-d96bd638750d5555c9a841b673c60b7707648867b17a0d964ce0c86b6c71761f.json => query-29db1a586ed9b1e8f9d8004e478d5a0ce197cb8786ba88c6584903ed9e9c02f4.json} (66%) rename .sqlx/{query-9b05fe20d2fd53e8c5f4aae509794f2db9fe3414770e2820d0481e0936588d69.json => query-fb1929e1a027a1cba0a4dc983f072377d49cac6936b8ae89d74dd7268adfa7e2.json} (66%) create mode 100644 supabase/migrations/20260522120000_billing_invoices_ext_split.sql diff --git a/.sqlx/query-d96bd638750d5555c9a841b673c60b7707648867b17a0d964ce0c86b6c71761f.json b/.sqlx/query-29db1a586ed9b1e8f9d8004e478d5a0ce197cb8786ba88c6584903ed9e9c02f4.json similarity index 66% rename from .sqlx/query-d96bd638750d5555c9a841b673c60b7707648867b17a0d964ce0c86b6c71761f.json rename to .sqlx/query-29db1a586ed9b1e8f9d8004e478d5a0ce197cb8786ba88c6584903ed9e9c02f4.json index 01b0ad7bb1e..dcb04a32bb3 100644 --- a/.sqlx/query-d96bd638750d5555c9a841b673c60b7707648867b17a0d964ce0c86b6c71761f.json +++ b/.sqlx/query-29db1a586ed9b1e8f9d8004e478d5a0ce197cb8786ba88c6584903ed9e9c02f4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n date_start as \"date_start!\",\n date_end as \"date_end!\",\n billed_prefix as \"billed_prefix!\",\n line_items as \"line_items!: sqlx::types::Json\",\n subtotal as \"subtotal!\",\n extra as \"extra!: sqlx::types::Json\",\n invoice_type as \"invoice_type!: InvoiceType\"\n FROM invoices_ext\n WHERE billed_prefix = $1\n AND ($2::date IS NULL OR date_start > $2)\n AND ($3::date IS NULL OR date_start < $3)\n AND ($4::date IS NULL OR date_end > $4)\n AND ($5::date IS NULL OR date_end < $5)\n AND ($6::text IS NULL OR invoice_type::text = $6)\n AND (\n $7::date IS NULL\n OR date_end < $7\n OR (date_end = $7 AND date_start < $8)\n OR (date_end = $7 AND date_start = $8 AND invoice_type::text > $9)\n )\n ORDER BY date_end DESC, date_start DESC, invoice_type ASC\n LIMIT $10\n ", + "query": "\n SELECT\n date_start as \"date_start!\",\n date_end as \"date_end!\",\n billed_prefix as \"billed_prefix!\",\n line_items as \"line_items!: sqlx::types::Json\",\n subtotal as \"subtotal!\",\n extra as \"extra!: sqlx::types::Json\",\n invoice_type as \"invoice_type!: InvoiceType\"\n FROM internal.invoices_ext\n WHERE billed_prefix = $1\n AND ($2::date IS NULL OR date_start > $2)\n AND ($3::date IS NULL OR date_start < $3)\n AND ($4::date IS NULL OR date_end > $4)\n AND ($5::date IS NULL OR date_end < $5)\n AND ($6::text IS NULL OR invoice_type::text = $6)\n AND (\n $7::date IS NULL\n OR date_end < $7\n OR (date_end = $7 AND date_start < $8)\n OR (date_end = $7 AND date_start = $8 AND invoice_type::text > $9)\n )\n ORDER BY date_end DESC, date_start DESC, invoice_type ASC\n LIMIT $10\n ", "describe": { "columns": [ { @@ -63,5 +63,5 @@ true ] }, - "hash": "d96bd638750d5555c9a841b673c60b7707648867b17a0d964ce0c86b6c71761f" + "hash": "29db1a586ed9b1e8f9d8004e478d5a0ce197cb8786ba88c6584903ed9e9c02f4" } diff --git a/.sqlx/query-9b05fe20d2fd53e8c5f4aae509794f2db9fe3414770e2820d0481e0936588d69.json b/.sqlx/query-fb1929e1a027a1cba0a4dc983f072377d49cac6936b8ae89d74dd7268adfa7e2.json similarity index 66% rename from .sqlx/query-9b05fe20d2fd53e8c5f4aae509794f2db9fe3414770e2820d0481e0936588d69.json rename to .sqlx/query-fb1929e1a027a1cba0a4dc983f072377d49cac6936b8ae89d74dd7268adfa7e2.json index f50a5f84698..86793a3f1dc 100644 --- a/.sqlx/query-9b05fe20d2fd53e8c5f4aae509794f2db9fe3414770e2820d0481e0936588d69.json +++ b/.sqlx/query-fb1929e1a027a1cba0a4dc983f072377d49cac6936b8ae89d74dd7268adfa7e2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n date_start as \"date_start!\",\n date_end as \"date_end!\",\n billed_prefix as \"billed_prefix!\",\n line_items as \"line_items!: sqlx::types::Json\",\n subtotal as \"subtotal!\",\n extra as \"extra!: sqlx::types::Json\",\n invoice_type as \"invoice_type!: InvoiceType\"\n FROM invoices_ext\n WHERE billed_prefix = $1\n AND ($2::date IS NULL OR date_start > $2)\n AND ($3::date IS NULL OR date_start < $3)\n AND ($4::date IS NULL OR date_end > $4)\n AND ($5::date IS NULL OR date_end < $5)\n AND ($6::text IS NULL OR invoice_type::text = $6)\n AND (\n $7::date IS NULL\n OR date_end > $7\n OR (date_end = $7 AND date_start > $8)\n OR (date_end = $7 AND date_start = $8 AND invoice_type::text < $9)\n )\n ORDER BY date_end ASC, date_start ASC, invoice_type DESC\n LIMIT $10\n ", + "query": "\n SELECT\n date_start as \"date_start!\",\n date_end as \"date_end!\",\n billed_prefix as \"billed_prefix!\",\n line_items as \"line_items!: sqlx::types::Json\",\n subtotal as \"subtotal!\",\n extra as \"extra!: sqlx::types::Json\",\n invoice_type as \"invoice_type!: InvoiceType\"\n FROM internal.invoices_ext\n WHERE billed_prefix = $1\n AND ($2::date IS NULL OR date_start > $2)\n AND ($3::date IS NULL OR date_start < $3)\n AND ($4::date IS NULL OR date_end > $4)\n AND ($5::date IS NULL OR date_end < $5)\n AND ($6::text IS NULL OR invoice_type::text = $6)\n AND (\n $7::date IS NULL\n OR date_end > $7\n OR (date_end = $7 AND date_start > $8)\n OR (date_end = $7 AND date_start = $8 AND invoice_type::text < $9)\n )\n ORDER BY date_end ASC, date_start ASC, invoice_type DESC\n LIMIT $10\n ", "describe": { "columns": [ { @@ -63,5 +63,5 @@ true ] }, - "hash": "9b05fe20d2fd53e8c5f4aae509794f2db9fe3414770e2820d0481e0936588d69" + "hash": "fb1929e1a027a1cba0a4dc983f072377d49cac6936b8ae89d74dd7268adfa7e2" } diff --git a/crates/control-plane-api/src/billing/db.rs b/crates/control-plane-api/src/billing/db.rs index f586e9cf1a8..3d7243d6914 100644 --- a/crates/control-plane-api/src/billing/db.rs +++ b/crates/control-plane-api/src/billing/db.rs @@ -63,7 +63,7 @@ pub async fn fetch_invoice_rows_forward( subtotal as "subtotal!", extra as "extra!: sqlx::types::Json", invoice_type as "invoice_type!: InvoiceType" - FROM invoices_ext + FROM internal.invoices_ext WHERE billed_prefix = $1 AND ($2::date IS NULL OR date_start > $2) AND ($3::date IS NULL OR date_start < $3) @@ -128,7 +128,7 @@ pub async fn fetch_invoice_rows_backward( subtotal as "subtotal!", extra as "extra!: sqlx::types::Json", invoice_type as "invoice_type!: InvoiceType" - FROM invoices_ext + FROM internal.invoices_ext WHERE billed_prefix = $1 AND ($2::date IS NULL OR date_start > $2) AND ($3::date IS NULL OR date_start < $3) diff --git a/supabase/migrations/20260522120000_billing_invoices_ext_split.sql b/supabase/migrations/20260522120000_billing_invoices_ext_split.sql new file mode 100644 index 00000000000..e1a64e4c205 --- /dev/null +++ b/supabase/migrations/20260522120000_billing_invoices_ext_split.sql @@ -0,0 +1,86 @@ +-- Split invoices_ext into a no-auth data view (internal.invoices_ext) and a +-- thin authorization wrapper (public.invoices_ext). Callers that have already +-- authorized the tenant (e.g. the control-plane-api GraphQL resolver) can query +-- internal.invoices_ext directly, which lets the planner push the +-- `billed_prefix = $1` predicate through UNION ALL into each branch and avoids +-- materializing every tenant via the authorized_tenants CTE. + +create view internal.invoices_ext as + select + (date_trunc('month'::text, ((billing_historicals.report ->> 'billed_month'::text))::timestamp with time zone))::date as date_start, + (((date_trunc('month'::text, ((billing_historicals.report ->> 'billed_month'::text))::timestamp with time zone) + '1 mon'::interval) - '1 day'::interval))::date as date_end, + (billing_historicals.tenant)::text as billed_prefix, + COALESCE(NULLIF((billing_historicals.report -> 'line_items'::text), 'null'::jsonb), '[]'::jsonb) as line_items, + (COALESCE(NULLIF((billing_historicals.report -> 'subtotal'::text), 'null'::jsonb), to_jsonb(0)))::integer as subtotal, + billing_historicals.report as extra, + 'final'::text as invoice_type + from internal.billing_historicals + + union all + + select + (date_trunc('month'::text, ((report.report ->> 'billed_month'::text))::timestamp with time zone))::date as date_start, + (((date_trunc('month'::text, ((report.report ->> 'billed_month'::text))::timestamp with time zone) + '1 mon'::interval) - '1 day'::interval))::date as date_end, + (tenants.tenant)::text as billed_prefix, + COALESCE(NULLIF((report.report -> 'line_items'::text), 'null'::jsonb), '[]'::jsonb) as line_items, + (COALESCE(NULLIF((report.report -> 'subtotal'::text), 'null'::jsonb), to_jsonb(0)))::integer as subtotal, + report.report as extra, + 'preview'::text as invoice_type + from public.tenants + join lateral generate_series( + (GREATEST('2023-08-01'::date, (date_trunc('month'::text, tenants.created_at))::date))::timestamp with time zone, + date_trunc('month'::text, ((now())::date)::timestamp with time zone), + '1 mon'::interval + ) invoice_month(invoice_month) + on not exists ( + select 1 + from internal.billing_historicals + where ((billing_historicals.tenant)::text ^@ (tenants.tenant)::text) + and ((date_trunc('month'::text, ((billing_historicals.report ->> 'billed_month'::text))::timestamp with time zone))::date = invoice_month.invoice_month) + ) + join lateral internal.billing_report_202308((tenants.tenant)::public.catalog_prefix, invoice_month.invoice_month) report(report) + on true + + union all + + select + manual_bills.date_start, + manual_bills.date_end, + (manual_bills.tenant)::text as billed_prefix, + jsonb_build_array(jsonb_build_object('description', manual_bills.description, 'count', 1, 'rate', manual_bills.usd_cents, 'subtotal', manual_bills.usd_cents)) as line_items, + manual_bills.usd_cents as subtotal, + 'null'::jsonb as extra, + 'manual'::text as invoice_type + from internal.manual_bills; + +comment on view internal.invoices_ext is + 'Tenant invoices (final + preview + manual) sans authorization.' + 'Use public.invoices_ext for the authorization-checked view.'; + +grant select on internal.invoices_ext to service_role; + +create or replace view public.invoices_ext as + with has_bypassrls as ( + select (exists ( + select 1 from pg_roles + where ((pg_roles.rolname = current_role) and (pg_roles.rolbypassrls = true)) + )) as bypass + ), + authorized_tenants as ( + select tenants.tenant, tenants.created_at + from public.tenants + left join has_bypassrls on (true) + left join public.auth_roles('admin'::public.grant_capability) auth_roles(role_prefix, capability) + on (((tenants.tenant)::text ^@ (auth_roles.role_prefix)::text)) + where (has_bypassrls.bypass or (auth_roles.role_prefix is not null)) + ) + select + invoices_ext.date_start, + invoices_ext.date_end, + (authorized_tenants.tenant)::text as billed_prefix, + invoices_ext.line_items, + invoices_ext.subtotal, + invoices_ext.extra, + invoices_ext.invoice_type + from internal.invoices_ext + join authorized_tenants on invoices_ext.billed_prefix = authorized_tenants.tenant::text;