feat(llm-gateway): report org credit-bucket spend on the usage endpoint#71404
Conversation
quota_limits now returns each resource's usage and limit from the org's synced billing numbers (the same usage + todays_usage sum the limiter compares; the boolean stays authoritative for gating). The gateway converts credit buckets to USD and exposes ai_credits.used_usd / limit_usd, cached alongside the existing bits. Absent numbers mean unknown, never zero — fail-open paths carry None. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
|
Hey @adboio! 👋 It looks like your git author email on this PR isn't your
You can fix it for this repo with: git config user.email "you@posthog.com"Or set it globally with |
Move the dense inline `usage + todays_usage` ternary (and its rationale) out of the response loop into a small `_resource_usage` helper, so the call site reads `"usage": _resource_usage(summary)`. Pure refactor — behavior is identical for synced, synced-unlimited, and never-synced resources. Generated-By: PostHog Code Task-Id: 5e82b22a-9b3c-4809-9fa2-b89a616d373f
| resource, | ||
| QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY, | ||
| ), | ||
| "usage": _resource_usage(summary), |
There was a problem hiding this comment.
Restrict organization spend to billing-authorized users
This project-read endpoint now returns organization-wide usage and limits for every quota resource. TeamAndOrgViewSetMixin admits any effective project member with project:read, whereas the billing usage/spend endpoints require IsOrganizationAdmin; thus a non-admin restricted to one project can call this endpoint (or use a project:read personal key) to obtain the entire organization's current billing consumption and credit limits, including activity attributable to other projects.
Prompt To Fix With AI
Do not include organization usage/limit amounts in the project-read quota response unless the caller has the same organization-level billing authorization as the billing spend/usage APIs (admin/owner and any owner-only-billing policy). If PostHog Code must expose the values to other users, introduce a narrowly scoped, explicitly authorized service/endpoint that returns only the necessary bucket and enforces the intended billing-data access policy. Add tests showing a project member and a project:read key without billing access cannot retrieve these amounts.Severity: medium | Confidence: 97% | React with 👍 if useful or 👎 if not
There was a problem hiding this comment.
intentional design choice; spend is relevant to users in posthog desktop app; can revisit later if needed
|
Reviews (1): Last reviewed commit: "refactor(quota-limits): extract per-reso..." | Re-trigger Greptile |
|
Note 🤖 stamphog reviewed This adds org-wide billing usage/limit numbers to an endpoint scoped only to project:read, so any project member (or a project-scoped personal API key) can now pull the organization's entire billing consumption — a real access-control gap on sensitive billing data, correctly flagged by hex-security-app and left unaddressed. A second unresolved bot finding (greptile) shows the null-usage case collapses to $0 instead of the "unknown" the PR explicitly claims to preserve.
Gate mechanics and policy version
|
|
Thanks for clarifying. The concern is specifically the authorization boundary: this route is available to any |
| resource, | ||
| QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY, | ||
| ), | ||
| "usage": _resource_usage(summary), |
There was a problem hiding this comment.
Medium: Organization usage exceeds the project scope
This project-scoped endpoint now returns organization-wide totals for every quota resource. An attacker holding a key restricted to one project can use it to read aggregate event, recording, AI, and other usage and limits across the organization, including sibling projects. Restrict the response to data authorized at the project level, or require an organization-level billing permission before returning these fields.
| # Entries written before this field existed read as False (capped) | ||
| # until the TTL turns them over. | ||
| code_usage_billing_active=bool(payload.get("code_usage_billing_active")), | ||
| used_usd=_optional_number(payload.get("used_usd")), |
There was a problem hiding this comment.
Medium: Cached spend bypasses token-scope authorization
The cache is keyed only by resource and team_id, and get_resource_status returns a hit before Django validates the forwarded credential. An attacker holding a token scoped to a different team or organization can therefore receive these newly cached spend values for the user's current team after an authorized request primes the cache. Bind cached spend to the credential's authorization context, or validate team and organization access before returning these fields from a shared cache.
PR overviewThis PR adds reporting of organization credit-bucket spend through the LLM gateway usage/quota endpoint. The touched quota-limit and quota-resolver code expands usage responses and caching around quota resource spend. Two security issues remain open, both involving authorization boundaries for newly exposed usage and spend data. A project-scoped or otherwise mismatched token can potentially read organization-wide or cross-team cached spend and quota information that should require stronger authorization. No issues have been addressed yet, so the PR still needs access-control tightening before it is safe to merge. Open issues (2)
Fixed/addressed: 0 · PR risk: 7/10 |
Guards the fix in the previous commit: a resource billing synced with null usage/todays_usage must report usage as null (unknown), not 0, so clients never render "$0 spent" while billing figures are incomplete. Adds a present-but-null case to the synced-resources endpoint test — the existing cases only cover real numbers and never-synced resources. Generated-By: PostHog Code Task-Id: 5e82b22a-9b3c-4809-9fa2-b89a616d373f
🤖 CI report |
Problem
PostHog Code is cutting over to usage-based billing, and the desktop app wants to show real org spend ("used $12.40 of your $50 limit") in the titlebar and on the Plan & usage page. Today
GET /v1/usage/{product}carries only per-user percentages against internal rate valves plus anai_credits.exhaustedboolean, so clients have nothing to render a dollar amount from. The numbers already exist inorganization.usage, synced from billing.Changes
GET /api/projects/{id}/quota_limits/now returnsusageandlimitper resource, read from the org's synced billing numbers.usageis the sameusage + todays_usagesum the quota limiter compares against the limit. Thelimitedboolean stays authoritative for gating (grace periods and refund offsets live only in the limiting decision). Both fields are null when billing hasn't synced the resource.GET /v1/usage/{product}exposes them asai_credits.used_usdandai_credits.limit_usd. Additive and optional, so old clients are unaffected.The per-user burst/sustained buckets still expose only percentages (
test_response_has_no_usd_fieldscontinues to guard that). The valve limits stay internal; the org's own bucket spend is the user's billing data.Note
The wire change is additive on both hops. Old gateway against new Django ignores the new keys; new gateway against old Django reads null and reports unknown.
How did you test this code?
ai_credits.test_quota_limits.pywith a synced-org case (1500 + 200 credits of 2000 reports usage 1700, a synced-but-unlimited bucket reports a null limit, a never-synced resource reports nulls, not zeros). I could not run the Django suite in this sandbox; CI covers it. Ruff (pinned version) is clean.quota_resolver.pyare untouched).Automatic notifications
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Authored by Claude (PostHog Code cloud session) at Adam's request, as the backend half of the usage-based billing client work in PostHog/code#3487, which wants to render org spend for both free-tier and subscribed orgs. Main decision: Django reports raw native units generically for every resource, and the credits-to-USD conversion lives in the gateway where the client contract is defined, rather than special-casing credit buckets in Django. Absent-means-unknown is preserved end to end so a resolver blip or unsynced org never renders as $0 spent.
Created with PostHog Code