billing: split invoices_ext data view from authorization wrapper#2970
Merged
Conversation
01f543d to
18424e0
Compare
`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.
18424e0 to
41ecb1d
Compare
GregorShear
approved these changes
May 28, 2026
Contributor
GregorShear
left a comment
There was a problem hiding this comment.
couldn't test the gql billing operations without setting up stripe integration on my local. instead I queried the views directly, and tested that they both return the same results, billed_prefix filter works, and changing my postgres user's role correctly denies access to the internal view.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
After merging #2883 and querying for invoices, I observed behavior that didn't show up locally: anything querying
tenant.billing.invoiceswould hang and eventually fail with a database timeout. Ultimately, this is because we're queryingpublic.invoices_extwhich is (somewhat poorly) designed to be queried by PostgREST, so it contains anauthorized_tenantsCTE that resolves the caller's admin-able tenants, joined into all three UNION ALL branches. This has some problems:The CTE blocks predicate pushdown. The resolver issues
WHERE billed_prefix = $1, butbilled_prefixin the view isauthorized_tenants.tenant, computed inside the CTE. The outer predicate can only filter the CTE's output, not the source tables, sobilling_historicalsis scanned with no tenant constraint and nested-loop joined against the CTE's row set.The preview branch is per-tenant-per-month. For each row of
authorized_tenantsit evaluatesgenerate_series(tenant.created_at, now(), '1 month')and callsinternal.billing_report_202308(tenant, month)for every month not yet frozen. That function readscatalog_statswith window functions per call.The resolver's predicate
($6::text IS NULL OR invoice_type::text = $6)cannot trigger UNION ALL branch elimination under a generic plan, because the filter is not a constant the planner can evaluate against each branch's literalinvoice_type. So the preview branch survives into the plan on every call once the prepared statement flips to generic.To resolve the problem, this change extracts the data logic into a new
internal.invoices_extview that directly queriesinternal.billing_historicals,public.tenants, andinternal.manual_bills.public.invoices_extis rewritten as a simple authz wrapper aroundinternal.invoices_extusing its existingauthorized_tenantsCTE, preserving the public view's authorization behavior and column shape. The GraphQL resolver, which already callsverify_authorizationbefore issuing SQL, queriesinternal.invoices_extdirectly.Note: I would like to take a deeper look at the whole invoice system in the future. As it stands, there is a lot of disjoint business logic and data models that I would like to bring together somehow: monthly usage-based invoices are stored in one place, generated by an enormously complex SQL function. Manually entered bills exist somewhere else, and then this view tries to paper over all of that and present a consistent surface.
I'd like to migrate it all to Rust eventually, but I felt that this PR was a justified middle ground to getting the new APIs working before we do.
Diff between old `public.invoices_ext` and new `internal.invoices_ext` showing that the net is only removing the authorization logic:
Following is the meat of a Claude-assisted analysis of the root cause:
Smoking gun: old plan
EXPLAINof the resolver's exact query with JWT session context set:Total estimated cost 26.4M, dominated by the preview branch's
Function Scan on internal.billing_report_202308.Smoking gun: new plan
Same resolver query against the new
internal.invoices_ext, inlined into the test query because the migration is not yet deployed. Two plan regimes verified.Constant-folded (literal SQL, planner can prune)
Preview and manual branches pruned at plan time. The historicals scan returns the 36 rows of
estuary/'s frozen invoices, top-N heapsorted to 2.Planner-opaque (simulates the prod generic-plan regime)
The resolver's prepared statement under a generic plan hides the parameter values from the planner. We simulate this by routing the tenant and type filters through
current_setting(), which isSTABLEbut not evaluable at plan time, so the planner cannot prune branches:Plan:
This is the actual prod regime. All three branches survive into the plan tree because the planner cannot decide the type filter at plan time. But each UNION ALL branch emits a literal
invoice_type('final','preview','manual'), so the predicate<literal> = current_setting(...)collapses to aOne-Time Filterevaluated once at execution start per branch. The two inactive branches' subtrees are marked(never executed).billing_report_202308is not invoked.