fix(db): enforce tenant ownership in session_pins and billing spend queries (#796) - #800
fix(db): enforce tenant ownership in session_pins and billing spend queries (#796)#800rohith500 wants to merge 3 commits into
Conversation
…r_month_spend, and GetUserMonthlySpendAndLimit (workweave#796) Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…writes (workweave#796) Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
…tUserMonthlySpendAndLimit (workweave#796) Signed-off-by: N Rohith Reddy <rohithreddy2202@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
|
PR author is not in the allowed authors list. |
|
Thank you so much for this, @rohith500 — this is a genuinely valuable hardening fix. You found four real tenant-ownership gaps (the same class as #536), the SQL is clean, the no-op-on-mismatch semantics are exactly right, and the before/after reproduction table in the description is excellent. The production code needed no changes at all. I've opened #806 as a convention-compliant version so we can land it smoothly, with you credited as co-author. The only thing moved is where the regression tests live — nothing about your logic changed. What changed and why: the four regression tests were added as a live-Postgres, DB-backed test (
So in #806 those exact four scenarios were ported verbatim (real Please don't read this as anything you did wrong — this convention is internal, still evolving, and not at all obvious from outside the repo. Your investigation and the fix itself were spot on, and it's landing because of your work. Thanks again! 🙏 |
Fixes #796.
Summary
Four SQL ownership gaps where tenant isolation was enforced only by the
application layer always passing trusted, same-tenant IDs, not by the SQL
itself. Same pattern as #536 (closed, fixed for
SoftDeleteModelRouterAPIKey), found here on four additional call sites:session_pins(reads and writes), and three billing queries (key_spendand
user_month_spendinDebitOrgCredits, plusGetUserMonthlySpendAndLimit). The fourth (user_month_spend) was foundduring the fix-design investigation, the same class of gap as the
GetUserMonthlySpendAndLimitread-side finding, just on the write side ofthe same query, included here since it's the same pattern and the same
file already being touched for
key_spend.Confirmed in #796 (independently verified twice before filing: an
independent investigation pass, and manual live SQL reproduction on local
Postgres) that none of these are reachable via any current authenticated
/v1/*request. This is a hardening fix, not a response to an activeexploit.
Fix
session_pins: addedAND installation_id = @installation_id::uuidtoGetSessionPin,UpdateSessionPinUsage,IncrementSessionPinUpstreamErrors, andResetSessionPinUpstreamErrors.UpsertSessionPin'sON CONFLICT DO UPDATEgainedWHERE router.session_pins.installation_id = EXCLUDED.installation_id, so amismatched caller's upsert silently no-ops rather than overwriting another
tenant's pin, without needing a PK migration.
installation_idthreadedthrough the
sessionpin.Storeinterface (Get,UpdateUsage,IncrementUpstreamErrors,ResetUpstreamErrors) and every call site(
loadPin,setForceModelPin,maybeRepinOnRefusal, loop-escalationhandling,
recordTurnUsage,maybeEvictPinAfterUpstreamErr,cyber-refusal re-pin, the feedback pin lookup).
SweepExpiredSessionPinsis intentionally untouched, it's global GC by
expires_at, nottenant-scoped.
key_spendanduser_month_spendCTEs (DebitOrgCredits): both now joinapi_key_id/router_user_idthrough tomodel_router_installations.external_idand confirm it matches theorganization_idbeing debited, before bumping the key's or user's spendcounter. A mismatched ID silently no-ops that one CTE, the org debit and
ledger row still proceed correctly, same no-op pattern the query already
uses for a missing key or deleted user.
GetUserMonthlySpendAndLimit: thehas_override,override_limit_usd_micros, andspent_usd_microssubqueries now jointhrough
model_router_userstomodel_router_installations.external_idand require it to match the
organization_idparameter.org_default_limit_usd_microsis correctly left unjoined, it'sorg-scoped only, with no user dimension.
All four: mismatch means a silent miss or no-op (0 spend, no override,
pin treated as not-found), not a new error path, matching how each of
these already handles a missing row today.
Commits
Postgres and real repo/service code
site
ownership joins
Testing
against pre-fix code for the right reason (the actual cross-tenant leak
occurring, not a setup error), then passing after each corresponding
fix commit.
after the session_pins commit, clean.
after the billing commit, clean.
including after a rebase onto origin/main (which had landed unrelated
changes touching internal/proxy/service.go and turnloop.go, fix(proxy): bypass engages through installation excluded_models #797/fix: preserve Anthropic context management fidelity #798
no file overlap with session_pins/billing queries; full suite +
make check re-run clean post-rebase).
after):
No migration required
All four fixes are query-level (plus sqlc regeneration); every column
referenced already exists.
Related
#796 (this PR fixes it), #536 (closed, same pattern precedent, different
table).