Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 35 additions & 4 deletions ee/api/quota_limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,34 @@ class QuotaResourceLimitSerializer(serializers.Serializer):
limited = serializers.BooleanField(
help_text="True when the team is currently over its quota for this resource and limits are in effect.",
)
usage = serializers.FloatField(
allow_null=True,
help_text=(
"Units of this resource the organization has used so far this billing period, in the "
"resource's native unit (credits for credit buckets). Null when billing hasn't synced "
"usage for the resource."
),
)
limit = serializers.FloatField(
allow_null=True,
help_text="The organization's limit for this resource in the same unit. Null when unlimited or unknown.",
)


def _resource_usage(summary: dict[str, Any]) -> float | None:
"""usage + todays_usage, the sum the quota limiter compares against the limit.

None rather than 0 when billing has never synced the resource, so clients read
it as unknown, not "$0 spent". The `limited` boolean stays authoritative for
gating; grace periods and refund offsets live only in that limiting decision.
"""
if not summary:
return None
usage = summary.get("usage")
todays_usage = summary.get("todays_usage")
if usage is None and todays_usage is None:
return None
return (usage or 0) + (todays_usage or 0)


class QuotaLimitsResponseSerializer(serializers.Serializer):
Expand Down Expand Up @@ -62,16 +90,19 @@ class QuotaLimitsViewSet(TeamAndOrgViewSetMixin, viewsets.ViewSet):
responses={200: QuotaLimitsResponseSerializer},
)
def list(self, request: Request, *args: Any, **kwargs: Any) -> Response:
limited = {
resource.value: {
org_usage = self.team.organization.usage or {}
limited = {}
for resource in QuotaResource:
summary = org_usage.get(resource.value) or {}
limited[resource.value] = {
"limited": is_team_limited(
self.team.api_token,
resource,
QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY,
),
"usage": _resource_usage(summary),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intentional design choice; spend is relevant to users in posthog desktop app; can revisit later if needed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

"limit": summary.get("limit"),
}
for resource in QuotaResource
}
return Response(
QuotaLimitsResponseSerializer(
{
Expand Down
35 changes: 29 additions & 6 deletions ee/api/test/test_quota_limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def test_session_auth_returns_under_quota_when_team_not_limited(self) -> None:
response = self.client.get(self._url())
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(data["limited"]["ai_credits"], {"limited": False})
self.assertEqual(data["limited"]["ai_credits"], {"limited": False, "usage": None, "limit": None})
# Org holds no billing-granted Code usage feature -> reads as not paying
self.assertIs(data["code_usage_billing_active"], False)

Expand All @@ -71,19 +71,42 @@ def test_reports_code_usage_billing_state(self) -> None:
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIs(response.json()["code_usage_billing_active"], True)

def test_reports_org_usage_and_limit_for_synced_resources(self) -> None:
# The LLM gateway forwards these to clients (PostHog Code renders
# "used $X of $Y"); usage mirrors the limiter's usage + todays_usage sum.
self.organization.usage = {
"period": ["2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"],
"posthog_code_credits": {"usage": 1500, "todays_usage": 200, "limit": 2000},
"ai_credits": {"usage": 50, "todays_usage": 0},
"signals_credits": {"usage": None, "todays_usage": None, "limit": 5000},
}
self.organization.save()

response = self.client.get(self._url())

self.assertEqual(response.status_code, status.HTTP_200_OK)
limited = response.json()["limited"]
self.assertEqual(limited["posthog_code_credits"], {"limited": False, "usage": 1700, "limit": 2000})
# Synced but unlimited: usage without a limit.
self.assertEqual(limited["ai_credits"], {"limited": False, "usage": 50, "limit": None})
# Synced with a limit but null usage figures: unknown usage, not zero.
self.assertEqual(limited["signals_credits"], {"limited": False, "usage": None, "limit": 5000})
# Never synced: unknown, not zero.
self.assertEqual(limited["events"], {"limited": False, "usage": None, "limit": None})

def test_returns_limited_when_team_is_over_quota(self) -> None:
self._set_ai_credits_limit(self.team.api_token, 9_999_999_999)

response = self.client.get(self._url())
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True})
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True, "usage": None, "limit": None})

def test_returns_unlimited_when_limit_has_already_expired(self) -> None:
self._set_ai_credits_limit(self.team.api_token, 1) # epoch 1970

response = self.client.get(self._url())
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": False})
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": False, "usage": None, "limit": None})

def test_personal_api_key_auth_works(self) -> None:
self.client.logout()
Expand All @@ -102,7 +125,7 @@ def test_personal_api_key_auth_works(self) -> None:
headers={"authorization": f"Bearer {raw_key}"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True})
self.assertEqual(response.json()["limited"]["ai_credits"], {"limited": True, "usage": None, "limit": None})

def test_user_not_in_teams_org_is_forbidden(self) -> None:
other_org = Organization.objects.create(name="other-org")
Expand Down Expand Up @@ -177,5 +200,5 @@ def test_multi_team_user_gets_per_team_answers(self) -> None:
resp_self = self.client.get(self._url())
resp_other = self.client.get(self._url(other_team.pk))

self.assertEqual(resp_self.json()["limited"]["ai_credits"], {"limited": True})
self.assertEqual(resp_other.json()["limited"]["ai_credits"], {"limited": False})
self.assertEqual(resp_self.json()["limited"]["ai_credits"], {"limited": True, "usage": None, "limit": None})
self.assertEqual(resp_other.json()["limited"]["ai_credits"], {"limited": False, "usage": None, "limit": None})
10 changes: 9 additions & 1 deletion services/llm-gateway/src/llm_gateway/api/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ class CostLimitStatus(BaseModel):

class AiCreditsStatus(BaseModel):
exhausted: bool
# Org-level bucket spend this billing period. None means unknown (unsynced
# org, resolver fail-open) — clients must not render None as $0.
used_usd: float | None = None
limit_usd: float | None = None


class UsageResponse(BaseModel):
Expand Down Expand Up @@ -139,7 +143,11 @@ async def get_usage(
user_id=user.user_id,
burst=burst_status,
sustained=sustained_status,
ai_credits=AiCreditsStatus(exhausted=credits_exhausted),
ai_credits=AiCreditsStatus(
exhausted=credits_exhausted,
used_usd=quota_status.used_usd,
limit_usd=quota_status.limit_usd,
),
is_rate_limited=burst_status.exceeded or sustained_status.exceeded or credits_exhausted,
is_pro=is_pro_plan(plan_info.plan_key),
code_usage_subscribed=quota_status.code_usage_billing_active,
Expand Down
31 changes: 30 additions & 1 deletion services/llm-gateway/src/llm_gateway/services/quota_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,30 @@
)


# Credit buckets are denominated in billing credits priced at one cent each
# (the billing product config for AI credits and `posthog_code_usage`).
_USD_PER_CREDIT = 0.01


@dataclass
class QuotaResourceStatus:
limited: bool
code_usage_billing_active: bool = False
# The org's bucket spend and limit this billing period, from billing's
# synced numbers. None means unknown (unsynced org, fail-open) — never $0.
used_usd: float | None = None
limit_usd: float | None = None


def _optional_number(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value)


def _credits_to_usd(credits: object) -> float | None:
value = _optional_number(credits)
return None if value is None else round(value * _USD_PER_CREDIT, 2)


class _TransientUpstreamError(Exception):
Expand Down Expand Up @@ -187,6 +207,8 @@ async def _fetch(self, resource_key: str, team_id: int, auth_header: str) -> tup
return QuotaResourceStatus(
limited=bool(resource.get("limited")),
code_usage_billing_active=bool(data.get("code_usage_billing_active")),
used_usd=_credits_to_usd(resource.get("usage")),
limit_usd=_credits_to_usd(resource.get("limit")),
), self._cache_ttl

async def _get_cached(self, resource_key: str, team_id: int) -> QuotaResourceStatus | None:
Expand All @@ -202,6 +224,8 @@ async def _get_cached(self, resource_key: str, team_id: int) -> QuotaResourceSta
# 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")),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

limit_usd=_optional_number(payload.get("limit_usd")),
)
except Exception:
logger.debug("quota_cache_read_failed", resource=resource_key, team_id=team_id)
Expand All @@ -212,7 +236,12 @@ async def _set_cached(self, resource_key: str, team_id: int, status: QuotaResour
return
try:
payload = json.dumps(
{"limited": status.limited, "code_usage_billing_active": status.code_usage_billing_active}
{
"limited": status.limited,
"code_usage_billing_active": status.code_usage_billing_active,
"used_usd": status.used_usd,
"limit_usd": status.limit_usd,
}
)
await self._redis.set(_redis_key(resource_key, team_id), payload, ex=ttl)
except Exception:
Expand Down
61 changes: 60 additions & 1 deletion services/llm-gateway/tests/test_quota_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,66 @@ async def test_billing_flag_falls_back_to_last_known_value_on_fetch_failure(self
assert json.loads(redis.store[_redis_key("ai_credits", 42)]) == {
"limited": False,
"code_usage_billing_active": True,
"used_usd": None,
"limit_usd": None,
}
assert redis.ttls[_redis_key("ai_credits", 42)] == _FAIL_OPEN_CACHE_TTL_SECONDS
else:
# 4xx is caller-specific and must not repopulate the shared entry.
assert _redis_key("ai_credits", 42) not in redis.store

@pytest.mark.asyncio
async def test_parses_credit_numbers_into_usd(self) -> None:
# Django reports credit-bucket usage/limit in credits (1 credit = $0.01);
# clients get dollars.
http_client = _make_http_client(
_make_response(
200,
{"limited": {"posthog_code_credits": {"limited": False, "usage": 1234, "limit": 2000}}},
)
)
resolver = QuotaResolver(redis=None, http_client=http_client)

status = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test")

assert status.used_usd == 12.34
assert status.limit_usd == 20.0

@pytest.mark.asyncio
async def test_missing_usage_numbers_read_as_unknown(self) -> None:
# Old Django responses and unsynced orgs carry no numbers — clients must
# see unknown, never $0.
http_client = _make_http_client(
_make_response(
200,
{"limited": {"posthog_code_credits": {"limited": False, "usage": None, "limit": None}}},
)
)
resolver = QuotaResolver(redis=None, http_client=http_client)

status = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test")

assert status.used_usd is None
assert status.limit_usd is None

@pytest.mark.asyncio
async def test_usd_numbers_round_trip_through_the_cache(self) -> None:
redis = _FakeRedis()
http_client = _make_http_client(
_make_response(
200,
{"limited": {"posthog_code_credits": {"limited": False, "usage": 1234, "limit": 5000}}},
)
)
resolver = QuotaResolver(redis=redis, http_client=http_client) # type: ignore[arg-type]

first = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test")
cached = await resolver.get_resource_status("posthog_code_credits", team_id=42, auth_header="Bearer phx_test")

assert (first.used_usd, first.limit_usd) == (12.34, 50.0)
assert (cached.used_usd, cached.limit_usd) == (12.34, 50.0)
assert http_client.get.await_count == 1

@pytest.mark.asyncio
async def test_fetches_and_parses_unlimited_response(self) -> None:
http_client = _make_http_client(
Expand Down Expand Up @@ -328,7 +382,12 @@ async def test_writes_cache_on_miss(self) -> None:
quota_writes = [c for c in redis.set.await_args_list if c.args[0] == _redis_key("ai_credits", 42)]
assert len(quota_writes) == 1
call = quota_writes[0]
assert json.loads(call.args[1]) == {"limited": True, "code_usage_billing_active": False}
assert json.loads(call.args[1]) == {
"limited": True,
"code_usage_billing_active": False,
"used_usd": None,
"limit_usd": None,
}
# Successful fetches use the gateway settings default of 5 minutes.
assert call.kwargs.get("ex") == 300
assert redis.set.await_count == 2
Expand Down
23 changes: 20 additions & 3 deletions services/llm-gateway/tests/test_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ def test_credits_reflect_products_own_bucket(self, authenticated_usage_client: T
)
assert response.status_code == 200
data = response.json()
assert data["ai_credits"] == {"exhausted": True}
assert data["ai_credits"] == {"exhausted": True, "used_usd": None, "limit_usd": None}
assert data["is_rate_limited"] is True
assert resolver_mock.call_args.args[0] == "posthog_code_credits"

Expand All @@ -404,7 +404,7 @@ def test_exhausted_bucket_reported_for_every_caller(
)
assert response.status_code == 200
data = response.json()
assert data["ai_credits"] == {"exhausted": True}
assert data["ai_credits"] == {"exhausted": True, "used_usd": None, "limit_usd": None}
assert data["is_rate_limited"] is True

def test_ai_credits_reflects_resolver_for_billable_product(self, authenticated_usage_client: TestClient) -> None:
Expand All @@ -420,10 +420,27 @@ def test_ai_credits_reflects_resolver_for_billable_product(self, authenticated_u
)
assert response.status_code == 200
data = response.json()
assert data["ai_credits"] == {"exhausted": True}
assert data["ai_credits"] == {"exhausted": True, "used_usd": None, "limit_usd": None}
assert data["is_rate_limited"] is True
assert resolver_mock.call_args.args[0] == "ai_credits"

def test_ai_credits_carries_org_spend_numbers(self, authenticated_usage_client: TestClient) -> None:
"""PostHog Code renders "used $X of $Y" (titlebar, plans page) off these
numbers; None must stay None so clients read unknown, not $0."""
from llm_gateway.services.quota_resolver import QuotaResourceStatus

app = authenticated_usage_client.app
app.state.quota_resolver.get_resource_status = AsyncMock(
return_value=QuotaResourceStatus(limited=False, used_usd=12.4, limit_usd=50.0)
)

response = authenticated_usage_client.get(
"/v1/usage/posthog_code",
headers={"Authorization": "Bearer phx_test"},
)
assert response.status_code == 200
assert response.json()["ai_credits"] == {"exhausted": False, "used_usd": 12.4, "limit_usd": 50.0}

@pytest.mark.parametrize("billing_active", [True, False])
def test_code_usage_subscribed_reflects_billing_bit(
self, authenticated_usage_client: TestClient, billing_active: bool
Expand Down
10 changes: 10 additions & 0 deletions services/mcp/src/api/generated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading