From 25e5064e4166bb08c767b1f4db410ae2c0302e6e Mon Sep 17 00:00:00 2001 From: neubig Date: Wed, 19 Aug 2026 04:23:17 +0000 Subject: [PATCH 01/13] feat: add daily conversation quota schema foundation Add migration 148 with a nullable per-user daily_conversation_limit override column on the user table and a daily_conversation_usage table for atomic per-user, per-UTC-day conversation accounting with a unique (user_id, usage_date) constraint. Register the model and add focused storage tests for the nullable override and uniqueness invariant. No enforcement or API behavior is introduced in this foundation; that lands in subsequent stacked PRs. Co-authored-by: openhands --- .../148_add_daily_conversation_limit.py | 53 +++++++++++++++++++ enterprise/storage/__init__.py | 2 + .../storage/daily_conversation_usage.py | 26 +++++++++ enterprise/storage/user.py | 2 + enterprise/tests/unit/conftest.py | 1 + .../storage/test_daily_conversation_usage.py | 24 +++++++++ 6 files changed, 108 insertions(+) create mode 100644 enterprise/migrations/versions/148_add_daily_conversation_limit.py create mode 100644 enterprise/storage/daily_conversation_usage.py create mode 100644 enterprise/tests/unit/storage/test_daily_conversation_usage.py diff --git a/enterprise/migrations/versions/148_add_daily_conversation_limit.py b/enterprise/migrations/versions/148_add_daily_conversation_limit.py new file mode 100644 index 000000000..48fa9f250 --- /dev/null +++ b/enterprise/migrations/versions/148_add_daily_conversation_limit.py @@ -0,0 +1,53 @@ +"""Add per-user daily conversation limits and usage accounting.""" + +import os +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '148' +down_revision: Union[str, None] = '147' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name != 'postgresql': + raise RuntimeError(f'Unsupported database dialect: {bind.dialect.name}') + + op.add_column( + 'user', + sa.Column('daily_conversation_limit', sa.Integer(), nullable=True), + ) + + # Persist the deployed default for existing users during the first rollout. + # Keeping NULL when the enterprise default is unset preserves unlimited mode + # and lets future chart changes take effect for users without an override. + raw_default = os.getenv('OH_DAILY_CONVERSATION_LIMIT', '').strip() + if raw_default: + op.execute( + sa.text( + 'UPDATE "user" SET daily_conversation_limit = :limit ' + 'WHERE daily_conversation_limit IS NULL' + ), + {'limit': int(raw_default)}, + ) + + op.create_table( + 'daily_conversation_usage', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('usage_date', sa.Date(), nullable=False), + sa.Column('conversation_count', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id']), + sa.UniqueConstraint('user_id', 'usage_date'), + ) + + +def downgrade() -> None: + op.drop_table('daily_conversation_usage') + op.drop_column('user', 'daily_conversation_limit') diff --git a/enterprise/storage/__init__.py b/enterprise/storage/__init__.py index d6df4fb6b..08cdcca18 100644 --- a/enterprise/storage/__init__.py +++ b/enterprise/storage/__init__.py @@ -3,6 +3,7 @@ from storage.billing_session import BillingSession from storage.billing_session_type import BillingSessionType from storage.conversation_work import ConversationWork +from storage.daily_conversation_usage import DailyConversationUsage from storage.feedback import ConversationFeedback, Feedback from storage.github_app_installation import GithubAppInstallation from storage.gitlab_webhook import GitlabWebhook, WebhookStatus @@ -51,6 +52,7 @@ 'ConversationFeedback', 'StoredConversationMetadataSaas', 'ConversationWork', + 'DailyConversationUsage', 'Feedback', 'GithubAppInstallation', 'GitlabWebhook', diff --git a/enterprise/storage/daily_conversation_usage.py b/enterprise/storage/daily_conversation_usage.py new file mode 100644 index 000000000..d06a35336 --- /dev/null +++ b/enterprise/storage/daily_conversation_usage.py @@ -0,0 +1,26 @@ +"""Daily conversation quota accounting models.""" + +from datetime import date, datetime +from uuid import UUID + +from sqlalchemy import Date, DateTime, ForeignKey, Integer, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from storage.base import Base + + +class DailyConversationUsage(Base): + """Atomic per-user conversation-start count for one UTC calendar day.""" + + __tablename__ = 'daily_conversation_usage' + __table_args__ = (UniqueConstraint('user_id', 'usage_date'),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[UUID] = mapped_column(ForeignKey('user.id'), nullable=False) + usage_date: Mapped[date] = mapped_column(Date, nullable=False) + conversation_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) diff --git a/enterprise/storage/user.py b/enterprise/storage/user.py index 3bf2a557b..04d977b76 100644 --- a/enterprise/storage/user.py +++ b/enterprise/storage/user.py @@ -52,6 +52,8 @@ class User(Base): onboarding_completed: Mapped[bool | None] = mapped_column( nullable=True, default=False ) + # NULL inherits the deployment-wide daily conversation limit. + daily_conversation_limit: Mapped[int | None] = mapped_column(nullable=True) # Relationships # Instance-level super-role relationship, not an org-scoped membership role. diff --git a/enterprise/tests/unit/conftest.py b/enterprise/tests/unit/conftest.py index 334de5c70..03ee72e6c 100644 --- a/enterprise/tests/unit/conftest.py +++ b/enterprise/tests/unit/conftest.py @@ -22,6 +22,7 @@ from storage.base import Base from storage.billing_session import BillingSession from storage.conversation_work import ConversationWork +from storage.daily_conversation_usage import DailyConversationUsage # noqa: F401 from storage.device_code import DeviceCode # noqa: F401 from storage.feedback import Feedback from storage.github_app_installation import GithubAppInstallation diff --git a/enterprise/tests/unit/storage/test_daily_conversation_usage.py b/enterprise/tests/unit/storage/test_daily_conversation_usage.py new file mode 100644 index 000000000..7a2140585 --- /dev/null +++ b/enterprise/tests/unit/storage/test_daily_conversation_usage.py @@ -0,0 +1,24 @@ +from sqlalchemy import inspect +from storage.daily_conversation_usage import DailyConversationUsage +from storage.user import User + + +def test_user_has_nullable_daily_conversation_limit(engine): + column = next( + column + for column in inspect(engine).get_columns(User.__tablename__) + if column["name"] == "daily_conversation_limit" + ) + + assert column["nullable"] is True + + +def test_daily_usage_has_unique_user_date_constraint(engine): + constraints = inspect(engine).get_unique_constraints( + DailyConversationUsage.__tablename__ + ) + + assert any( + constraint["column_names"] == ["user_id", "usage_date"] + for constraint in constraints + ) From a5e676f2f3f74225fe33d91de35c64094b05659a Mon Sep 17 00:00:00 2001 From: neubig Date: Wed, 19 Aug 2026 04:57:49 +0000 Subject: [PATCH 02/13] fix: use single quotes in storage test to satisfy ruff Co-authored-by: openhands --- .../tests/unit/storage/test_daily_conversation_usage.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/enterprise/tests/unit/storage/test_daily_conversation_usage.py b/enterprise/tests/unit/storage/test_daily_conversation_usage.py index 7a2140585..d474769bb 100644 --- a/enterprise/tests/unit/storage/test_daily_conversation_usage.py +++ b/enterprise/tests/unit/storage/test_daily_conversation_usage.py @@ -7,10 +7,10 @@ def test_user_has_nullable_daily_conversation_limit(engine): column = next( column for column in inspect(engine).get_columns(User.__tablename__) - if column["name"] == "daily_conversation_limit" + if column['name'] == "daily_conversation_limit" ) - assert column["nullable"] is True + assert column['nullable'] is True def test_daily_usage_has_unique_user_date_constraint(engine): @@ -19,6 +19,6 @@ def test_daily_usage_has_unique_user_date_constraint(engine): ) assert any( - constraint["column_names"] == ["user_id", "usage_date"] + constraint['column_names'] == ['user_id', 'usage_date'] for constraint in constraints ) From b199912dbf42a4299d961d696d0299dfcc215caa Mon Sep 17 00:00:00 2001 From: neubig Date: Wed, 19 Aug 2026 04:30:03 +0000 Subject: [PATCH 03/13] feat: add read-only quota usage page with reset countdown Add GET /api/quota/status returning the authenticated user's effective daily limit, used count, remaining, and next UTC-midnight reset_at. Add a SaaS-only settings page at /settings/quota showing a progress bar and a live HH:MM:SS countdown to the next reset. Includes focused tests for the quota status service covering unlimited, partial, exhausted, and no-usage-today cases. Stacked on feat/daily-conversation-limit (PR #180). Co-authored-by: openhands --- enterprise/saas_server.py | 2 + enterprise/server/routes/quota.py | 37 +++++ .../daily_conversation_quota_service.py | 77 ++++++++++ .../test_daily_conversation_quota_service.py | 84 +++++++++++ .../api/quota-service/quota-service.api.ts | 15 ++ frontend/src/constants/settings-nav.tsx | 6 + frontend/src/hooks/query/use-quota-status.ts | 13 ++ frontend/src/i18n/declaration.ts | 7 + frontend/src/i18n/translation.json | 119 +++++++++++++++ frontend/src/routes.ts | 1 + frontend/src/routes/quota-settings.tsx | 136 ++++++++++++++++++ 11 files changed, 497 insertions(+) create mode 100644 enterprise/server/routes/quota.py create mode 100644 enterprise/server/services/daily_conversation_quota_service.py create mode 100644 enterprise/tests/unit/test_daily_conversation_quota_service.py create mode 100644 frontend/src/api/quota-service/quota-service.api.ts create mode 100644 frontend/src/hooks/query/use-quota-status.ts create mode 100644 frontend/src/routes/quota-settings.tsx diff --git a/enterprise/saas_server.py b/enterprise/saas_server.py index fe1ca1967..c731ecb6e 100644 --- a/enterprise/saas_server.py +++ b/enterprise/saas_server.py @@ -51,6 +51,7 @@ from server.routes.org_invitations import ( # noqa: E402 invitation_router, ) +from server.routes.quota import quota_router # noqa: E402 from server.routes.org_profiles import router as org_profiles_router # noqa: E402 from server.routes.orgs import org_router # noqa: E402 from server.routes.readiness import readiness_router # noqa: E402 @@ -209,6 +210,7 @@ def is_saas(): base_app.include_router( analytics_events_router ) # Add routes for client-initiated analytics events +base_app.include_router(quota_router) # Add routes for quota status base_app.add_middleware( diff --git a/enterprise/server/routes/quota.py b/enterprise/server/routes/quota.py new file mode 100644 index 000000000..652dc35dc --- /dev/null +++ b/enterprise/server/routes/quota.py @@ -0,0 +1,37 @@ +"""Quota status API for the settings page.""" + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from server.services.daily_conversation_quota_service import ( + DailyConversationQuotaService, +) +from sqlalchemy.ext.asyncio import AsyncSession + +from openhands.app_server.user_auth import get_user_id +from openhands.app_server.utils.dependencies import get_dependencies +from storage.database import a_session_maker + +quota_router = APIRouter( + prefix='/api/quota', tags=['Quota'], dependencies=get_dependencies() +) + + +class QuotaStatusResponse(BaseModel): + daily_limit: int | None + used_today: int + remaining: int | None + reset_at: str + + +@quota_router.get('/status', response_model=QuotaStatusResponse) +async def get_quota_status(user_id: str = Depends(get_user_id)) -> QuotaStatusResponse: + """Return the authenticated user's daily conversation quota status.""" + async with a_session_maker() as session: + service = DailyConversationQuotaService(session) + status = await service.get_status(user_id) + return QuotaStatusResponse( + daily_limit=status.daily_limit, + used_today=status.used_today, + remaining=status.remaining, + reset_at=status.reset_at, + ) diff --git a/enterprise/server/services/daily_conversation_quota_service.py b/enterprise/server/services/daily_conversation_quota_service.py new file mode 100644 index 000000000..9f7cc16c4 --- /dev/null +++ b/enterprise/server/services/daily_conversation_quota_service.py @@ -0,0 +1,77 @@ +"""Read-only daily conversation quota status for the authenticated user.""" + +from __future__ import annotations + +import os +from datetime import UTC, date, datetime, timedelta +from uuid import UUID + +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from storage.daily_conversation_usage import DailyConversationUsage +from storage.user import User + +DEFAULT_ENV_VAR = 'OH_DAILY_CONVERSATION_LIMIT' + + +def configured_daily_limit() -> int | None: + """Read the deployment default; unset and blank mean unlimited.""" + raw = os.getenv(DEFAULT_ENV_VAR) + if raw is None or not raw.strip(): + return None + return int(raw) + + +class QuotaStatus(BaseModel): + """Current daily quota snapshot for the settings page.""" + + daily_limit: int | None + used_today: int + remaining: int | None + reset_at: str + + +class DailyConversationQuotaService: + """Read-only quota status. Enforcement is added in a later stacked PR.""" + + def __init__(self, db_session: AsyncSession) -> None: + self.db_session = db_session + + async def get_status(self, user_id: str) -> QuotaStatus: + limit = await self.get_limit(user_id) + used = await self._used(user_id, datetime.now(UTC).date()) + remaining = None if limit is None else max(limit - used, 0) + reset_at = self._next_reset_iso() + return QuotaStatus( + daily_limit=limit, + used_today=used, + remaining=remaining, + reset_at=reset_at, + ) + + async def get_limit(self, user_id: str) -> int | None: + user = await self.db_session.scalar( + select(User).where(User.id == UUID(user_id)) + ) + if user is None or user.daily_conversation_limit is None: + return configured_daily_limit() + return user.daily_conversation_limit + + @staticmethod + def _next_reset_iso() -> str: + """ISO timestamp of the next UTC midnight.""" + today = datetime.now(UTC).date() + reset = datetime.combine( + today + timedelta(days=1), datetime.min.time(), tzinfo=UTC + ) + return reset.isoformat() + + async def _used(self, user_id: str, usage_date: date) -> int: + usage = await self.db_session.scalar( + select(DailyConversationUsage).where( + DailyConversationUsage.user_id == UUID(user_id), + DailyConversationUsage.usage_date == usage_date, + ) + ) + return usage.conversation_count if usage else 0 diff --git a/enterprise/tests/unit/test_daily_conversation_quota_service.py b/enterprise/tests/unit/test_daily_conversation_quota_service.py new file mode 100644 index 000000000..0a9615d0a --- /dev/null +++ b/enterprise/tests/unit/test_daily_conversation_quota_service.py @@ -0,0 +1,84 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from server.services.daily_conversation_quota_service import ( + DailyConversationQuotaService, + QuotaStatus, +) + +USER_ID = str(uuid4()) + + +@pytest.mark.asyncio +async def test_get_status_unlimited(): + """When limit is None (unlimited), remaining is None and reset_at is next UTC midnight.""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=None), + None, # no usage record for today + ] + + service = DailyConversationQuotaService(session) + result = await service.get_status(USER_ID) + + assert isinstance(result, QuotaStatus) + assert result.daily_limit is None + assert result.remaining is None + assert result.reset_at.endswith('T00:00:00+00:00') + + +@pytest.mark.asyncio +async def test_get_status_with_limit_and_usage(): + """When limit is set and some conversations used, remaining is limit - used.""" + session = AsyncMock() + + # First scalar call: User lookup returns a user with limit 20 + # Second scalar call: DailyConversationUsage lookup returns count 5 + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=20), + SimpleNamespace(conversation_count=5), + ] + + service = DailyConversationQuotaService(session) + result = await service.get_status(USER_ID) + + assert result.daily_limit == 20 + assert result.used_today == 5 + assert result.remaining == 15 + assert result.reset_at.endswith('T00:00:00+00:00') + + +@pytest.mark.asyncio +async def test_get_status_remaining_floor_zero(): + """Remaining never goes below zero even if usage exceeds limit.""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=10), + SimpleNamespace(conversation_count=12), + ] + + service = DailyConversationQuotaService(session) + result = await service.get_status(USER_ID) + + assert result.daily_limit == 10 + assert result.used_today == 12 + assert result.remaining == 0 + + +@pytest.mark.asyncio +async def test_get_status_no_usage_today(): + """When no usage record exists for today, used_today is 0.""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=20), + None, + ] + + service = DailyConversationQuotaService(session) + result = await service.get_status(USER_ID) + + assert result.daily_limit == 20 + assert result.used_today == 0 + assert result.remaining == 20 diff --git a/frontend/src/api/quota-service/quota-service.api.ts b/frontend/src/api/quota-service/quota-service.api.ts new file mode 100644 index 000000000..146a1c688 --- /dev/null +++ b/frontend/src/api/quota-service/quota-service.api.ts @@ -0,0 +1,15 @@ +import { openHands } from "#/api/open-hands-axios"; + +export interface QuotaStatus { + daily_limit: number | null; + used_today: number; + remaining: number | null; + reset_at: string; +} + +export const quotaService = { + getStatus: async (): Promise => { + const { data } = await openHands.get("/api/quota/status"); + return data; + }, +}; diff --git a/frontend/src/constants/settings-nav.tsx b/frontend/src/constants/settings-nav.tsx index 2edc074cb..7495d9b7f 100644 --- a/frontend/src/constants/settings-nav.tsx +++ b/frontend/src/constants/settings-nav.tsx @@ -144,6 +144,12 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [ text: "SETTINGS$NAV_BILLING", section: "billing", }, + { + icon: , + to: "/settings/quota", + text: "SETTINGS$NAV_QUOTA", + section: "user", + }, { icon: , to: "/settings/integrations", diff --git a/frontend/src/hooks/query/use-quota-status.ts b/frontend/src/hooks/query/use-quota-status.ts new file mode 100644 index 000000000..9f49e42ee --- /dev/null +++ b/frontend/src/hooks/query/use-quota-status.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { quotaService } from "#/api/quota-service/quota-service.api"; + +export const QUOTA_QUERY_KEYS = { + status: ["quota", "status"] as const, +}; + +export const useQuotaStatus = () => + useQuery({ + queryKey: QUOTA_QUERY_KEYS.status, + queryFn: quotaService.getStatus, + refetchInterval: 60_000, // refresh every minute so the countdown stays live + }); diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 16df9948e..61703ab50 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -1718,4 +1718,11 @@ export enum I18nKey { SETTINGS$DELETE_CONFIRMATION_MESSAGE = "SETTINGS$DELETE_CONFIRMATION_MESSAGE", BUTTON$EDIT = "BUTTON$EDIT", SETTINGS$MARKETPLACE_SOURCE_REQUIRED = "SETTINGS$MARKETPLACE_SOURCE_REQUIRED", + SETTINGS$NAV_QUOTA = "SETTINGS$NAV_QUOTA", + SETTINGS$QUOTA_DAILY_LIMIT = "SETTINGS$QUOTA_DAILY_LIMIT", + SETTINGS$QUOTA_USED_TODAY = "SETTINGS$QUOTA_USED_TODAY", + SETTINGS$QUOTA_REMAINING = "SETTINGS$QUOTA_REMAINING", + SETTINGS$QUOTA_UNLIMITED = "SETTINGS$QUOTA_UNLIMITED", + SETTINGS$QUOTA_RESETS_IN = "SETTINGS$QUOTA_RESETS_IN", + SETTINGS$QUOTA_SALES_ONLY = "SETTINGS$QUOTA_SALES_ONLY", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 867161943..9a1a752c3 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -29204,5 +29204,124 @@ "ca": "L'origen és obligatori", "tr": "Kaynak gereklidir", "uk": "Джерело є обов'язковим" + }, + "SETTINGS$NAV_QUOTA": { + "en": "Quota", + "ja": "クオータ", + "zh-CN": "配额", + "zh-TW": "配額", + "ko-KR": "할당량", + "no": "Kvote", + "it": "Quota", + "pt": "Cota", + "es": "Cuota", + "ar": "الحصة", + "fr": "Quota", + "tr": "Kota", + "de": "Kontingent", + "uk": "Квота", + "pt-BR": "Cota" + }, + "SETTINGS$QUOTA_DAILY_LIMIT": { + "en": "Daily limit", + "ja": "1日の制限", + "zh-CN": "每日限制", + "zh-TW": "每日限制", + "ko-KR": "일일 한도", + "no": "Daglig grense", + "it": "Limite giornaliero", + "pt": "Limite diário", + "es": "Límite diario", + "ar": "الحد اليومي", + "fr": "Limite quotidienne", + "tr": "Günlük limit", + "de": "Tageslimit", + "uk": "Денний ліміт", + "pt-BR": "Limite diário" + }, + "SETTINGS$QUOTA_USED_TODAY": { + "en": "Used today", + "ja": "今日の使用量", + "zh-CN": "今日已用", + "zh-TW": "今日已用", + "ko-KR": "오늘 사용량", + "no": "Brukt i dag", + "it": "Usato oggi", + "pt": "Usado hoje", + "es": "Usado hoy", + "ar": "المستخدم اليوم", + "fr": "Utilisé aujourd hui", + "tr": "Bugün kullanılan", + "de": "Heute verwendet", + "uk": "Використано сьогодні", + "pt-BR": "Usado hoje" + }, + "SETTINGS$QUOTA_REMAINING": { + "en": "Remaining", + "ja": "残り", + "zh-CN": "剩余", + "zh-TW": "剩餘", + "ko-KR": "남은 횟수", + "no": "Gjenværende", + "it": "Rimanente", + "pt": "Restante", + "es": "Restante", + "ar": "المتبقي", + "fr": "Restant", + "tr": "Kalan", + "de": "Verbleibend", + "uk": "Залишилось", + "pt-BR": "Restante" + }, + "SETTINGS$QUOTA_UNLIMITED": { + "en": "Unlimited", + "ja": "無制限", + "zh-CN": "无限", + "zh-TW": "無限", + "ko-KR": "무제한", + "no": "Ubegrenset", + "it": "Illimitato", + "pt": "Ilimitado", + "es": "Ilimitado", + "ar": "غير محدود", + "fr": "Illimité", + "tr": "Sınırsız", + "de": "Unbegrenzt", + "uk": "Безлімітний", + "pt-BR": "Ilimitado" + }, + "SETTINGS$QUOTA_RESETS_IN": { + "en": "Resets in", + "ja": "リセットまで", + "zh-CN": "重置于", + "zh-TW": "重置於", + "ko-KR": "재설정까지", + "no": "Tilbakestilles om", + "it": "Reimposta tra", + "pt": "Redefinir em", + "es": "Se reinicia en", + "ar": "إعادة التعيين في", + "fr": "Réinitialisé dans", + "tr": "Sıfırlanmaya", + "de": "Zurücksetzen in", + "uk": "Скидається через", + "pt-BR": "Redefinir em" + }, + "SETTINGS$QUOTA_SALES_ONLY": { + "en": "Quota management is only available in SaaS mode.", + "ja": "クオータ管理はSaaSモードでのみ利用できます。", + "zh-CN": "配额管理仅在SaaS模式下可用。", + "zh-TW": "配額管理僅在SaaS模式下可用。", + "ko-KR": "할당량 관리는 SaaS 모드에서만 사용할 수 있습니다.", + "no": "Kvotestyring er kun tilgjengelig i SaaS-modus.", + "it": "La gestione della quota è disponibile solo in modalità SaaS.", + "pt": "O gerenciamento de cota está disponível apenas no modo SaaS.", + "es": "La gestión de cuota solo está disponible en modo SaaS.", + "ar": "إدارة الحصة متاحة فقط في وضع SaaS.", + "fr": "La gestion du quota est disponible uniquement en mode SaaS.", + "tr": "Kota yönetimi yalnızca SaaS modunda kullanılabilir.", + "de": "Kontingentverwaltung ist nur im SaaS-Modus verfügbar.", + "uk": "Управління квотою доступне лише в режимі SaaS.", + "pt-BR": "O gerenciamento de cota está disponível apenas no modo SaaS." } } diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 1956c209a..5f381cab2 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -47,6 +47,7 @@ export default [ route("usage-monitoring", "routes/usage-monitoring.tsx"), route("admin-dashboard", "routes/admin-dashboard.tsx"), route("budgets", "routes/budgets.tsx"), + route("quota", "routes/quota-settings.tsx"), ]), route("conversations/:conversationId", "routes/conversation.tsx"), route("oauth/device/verify", "routes/device-verify.tsx"), diff --git a/frontend/src/routes/quota-settings.tsx b/frontend/src/routes/quota-settings.tsx new file mode 100644 index 000000000..0758f4dad --- /dev/null +++ b/frontend/src/routes/quota-settings.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuotaStatus } from "#/hooks/query/use-quota-status"; +import { useConfig } from "#/hooks/query/use-config"; +import { I18nKey } from "#/i18n/declaration"; + +function useCountdown(resetAt: string | null) { + const [remaining, setRemaining] = useState(""); + + useEffect(() => { + if (!resetAt) { + setRemaining(""); + return; + } + + const update = () => { + const diff = new Date(resetAt).getTime() - Date.now(); + if (diff <= 0) { + setRemaining("00:00:00"); + return; + } + const h = Math.floor(diff / 3_600_000); + const m = Math.floor((diff % 3_600_000) / 60_000); + const s = Math.floor((diff % 60_000) / 1_000); + setRemaining( + `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`, + ); + }; + + update(); + const id = setInterval(update, 1_000); + return () => clearInterval(id); + }, [resetAt]); + + return remaining; +} + +function QuotaSettingsScreen() { + const { t } = useTranslation(); + const { data: config } = useConfig(); + const { data: quota, isLoading } = useQuotaStatus(); + const countdown = useCountdown(quota?.reset_at ?? null); + + const isSaas = config?.app_mode === "saas"; + + if (!isSaas) { + return ( +
+

+ {t(I18nKey.SETTINGS$QUOTA_SALES_ONLY)} +

+
+ ); + } + + if (isLoading || !quota) { + return ( +
+
+
+ ); + } + + const unlimited = quota.daily_limit === null; + const pct = + unlimited || quota.daily_limit === 0 + ? 0 + : Math.min((quota.used_today / quota.daily_limit) * 100, 100); + + return ( +
+

+ {t(I18nKey.SETTINGS$NAV_QUOTA)} +

+ +
+
+ + {t(I18nKey.SETTINGS$QUOTA_DAILY_LIMIT)} + + + {unlimited + ? t(I18nKey.SETTINGS$QUOTA_UNLIMITED) + : quota.daily_limit} + +
+ +
+ + {t(I18nKey.SETTINGS$QUOTA_USED_TODAY)} + + + {quota.used_today} + +
+ +
+ + {t(I18nKey.SETTINGS$QUOTA_REMAINING)} + + + {unlimited ? t(I18nKey.SETTINGS$QUOTA_UNLIMITED) : quota.remaining} + +
+ + {!unlimited && ( +
+
+
+ )} +
+ +
+ {t(I18nKey.SETTINGS$QUOTA_RESETS_IN)} + + {countdown} + +
+
+ ); +} + +export default QuotaSettingsScreen; From 6caa8e166528a0d59484f6be98fae0491bfc414b Mon Sep 17 00:00:00 2001 From: neubig Date: Wed, 19 Aug 2026 05:02:40 +0000 Subject: [PATCH 04/13] fix: handle nullable daily_limit in TypeScript for quota settings Co-authored-by: openhands --- frontend/src/routes/quota-settings.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/quota-settings.tsx b/frontend/src/routes/quota-settings.tsx index 0758f4dad..bf8a9e4d9 100644 --- a/frontend/src/routes/quota-settings.tsx +++ b/frontend/src/routes/quota-settings.tsx @@ -65,10 +65,11 @@ function QuotaSettingsScreen() { } const unlimited = quota.daily_limit === null; + const limit = quota.daily_limit ?? 0; const pct = - unlimited || quota.daily_limit === 0 + unlimited || limit === 0 ? 0 - : Math.min((quota.used_today / quota.daily_limit) * 100, 100); + : Math.min((quota.used_today / limit) * 100, 100); return (
From 757dfb14ead833f13a621c73ee01d8e77230b625 Mon Sep 17 00:00:00 2001 From: neubig Date: Thu, 20 Aug 2026 13:34:04 +0000 Subject: [PATCH 05/13] feat: add org-level daily conversation quota exemptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add migration 149_org_quota with a nullable daily_conversation_limit column on the org table. Update the quota service to resolve limits with the precedence: user override → org override → env default → None. Org-level exemptions use -1 to mean unlimited (NULL inherits the deployment default). This allows paying SaaS orgs to be exempted from daily conversation limits while still enforcing limits for other orgs. Add PUT /api/admin/quota/orgs/{org_id}/quota admin endpoint for setting or clearing org-level limits. 6 focused tests covering user precedence, org override, org exemption, env fallback, and unset/unlimited resolution. Stacked on feat/quota-usage-page (PR #199). Co-authored-by: openhands --- ..._quota_add_org_daily_conversation_limit.py | 26 ++++++ enterprise/saas_server.py | 3 +- enterprise/server/routes/quota.py | 73 ++++++++++++++- .../daily_conversation_quota_service.py | 34 ++++++- enterprise/storage/org.py | 4 + .../test_daily_conversation_quota_service.py | 13 +-- enterprise/tests/unit/test_org_level_quota.py | 89 +++++++++++++++++++ 7 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 enterprise/migrations/versions/149_org_quota_add_org_daily_conversation_limit.py create mode 100644 enterprise/tests/unit/test_org_level_quota.py diff --git a/enterprise/migrations/versions/149_org_quota_add_org_daily_conversation_limit.py b/enterprise/migrations/versions/149_org_quota_add_org_daily_conversation_limit.py new file mode 100644 index 000000000..e96562ab4 --- /dev/null +++ b/enterprise/migrations/versions/149_org_quota_add_org_daily_conversation_limit.py @@ -0,0 +1,26 @@ +"""Add org-level daily conversation limit override.""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = '149_org_quota' +down_revision: Union[str, None] = '148' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name != 'postgresql': + raise RuntimeError(f'Unsupported database dialect: {bind.dialect.name}') + + op.add_column( + 'org', + sa.Column('daily_conversation_limit', sa.Integer(), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column('org', 'daily_conversation_limit') diff --git a/enterprise/saas_server.py b/enterprise/saas_server.py index c731ecb6e..e2e22ed37 100644 --- a/enterprise/saas_server.py +++ b/enterprise/saas_server.py @@ -51,7 +51,7 @@ from server.routes.org_invitations import ( # noqa: E402 invitation_router, ) -from server.routes.quota import quota_router # noqa: E402 +from server.routes.quota import quota_admin_router, quota_router # noqa: E402 from server.routes.org_profiles import router as org_profiles_router # noqa: E402 from server.routes.orgs import org_router # noqa: E402 from server.routes.readiness import readiness_router # noqa: E402 @@ -211,6 +211,7 @@ def is_saas(): analytics_events_router ) # Add routes for client-initiated analytics events base_app.include_router(quota_router) # Add routes for quota status +base_app.include_router(quota_admin_router) # Add admin routes for org-level quota base_app.add_middleware( diff --git a/enterprise/server/routes/quota.py b/enterprise/server/routes/quota.py index 652dc35dc..13d345519 100644 --- a/enterprise/server/routes/quota.py +++ b/enterprise/server/routes/quota.py @@ -1,14 +1,16 @@ -"""Quota status API for the settings page.""" +"""Quota status and org-level quota management API.""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from server.services.daily_conversation_quota_service import ( DailyConversationQuotaService, ) +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from openhands.app_server.user_auth import get_user_id from openhands.app_server.utils.dependencies import get_dependencies +from openhands.app_server.utils.logger import openhands_logger as logger from storage.database import a_session_maker quota_router = APIRouter( @@ -35,3 +37,70 @@ async def get_quota_status(user_id: str = Depends(get_user_id)) -> QuotaStatusRe remaining=status.remaining, reset_at=status.reset_at, ) + + +# --- Org-level quota management (admin) --- + +quota_admin_router = APIRouter( + prefix='/api/admin/quota', tags=['Admin'], dependencies=get_dependencies() +) + + +class OrgQuotaUpdateRequest(BaseModel): + daily_conversation_limit: int | None + + +class OrgQuotaResponse(BaseModel): + org_id: str + org_name: str + daily_conversation_limit: int | None + + +async def _require_admin(user_id: str) -> None: + """Check that the user is an admin (has superadmin role).""" + from server.auth.authorization import get_user_super_role + + role = await get_user_super_role(user_id) + if role is None or role.name != 'admin': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail='Admin access required', + ) + + +@quota_admin_router.put( + '/orgs/{org_id}/quota', response_model=OrgQuotaResponse +) +async def set_org_quota( + org_id: str, + body: OrgQuotaUpdateRequest, + user_id: str = Depends(get_user_id), +) -> OrgQuotaResponse: + """Set or clear an org-level daily conversation limit override. + + Admin only. Set to -1 to exempt the org entirely (unlimited). + Set to NULL to inherit the deployment default. + Set to a positive integer for an org-specific limit. + """ + await _require_admin(user_id) + from uuid import UUID + + from storage.org import Org + + async with a_session_maker() as session: + org = await session.scalar( + select(Org).where(Org.id == UUID(org_id)) + ) + if org is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail='Organization not found', + ) + org.daily_conversation_limit = body.daily_conversation_limit + await session.commit() + + return OrgQuotaResponse( + org_id=str(org.id), + org_name=org.name, + daily_conversation_limit=org.daily_conversation_limit, + ) diff --git a/enterprise/server/services/daily_conversation_quota_service.py b/enterprise/server/services/daily_conversation_quota_service.py index 9f7cc16c4..d7b1f7894 100644 --- a/enterprise/server/services/daily_conversation_quota_service.py +++ b/enterprise/server/services/daily_conversation_quota_service.py @@ -51,12 +51,42 @@ async def get_status(self, user_id: str) -> QuotaStatus: ) async def get_limit(self, user_id: str) -> int | None: + """Resolve the effective daily conversation limit. + + Precedence (first non-None wins): + 1. User-level override (``user.daily_conversation_limit``) + 2. Org-level override (``org.daily_conversation_limit``) + 3. Deployment default (``OH_DAILY_CONVERSATION_LIMIT`` env var) + + NULL at any level means "inherit from the next level down." + A NULL org override means the org is exempt (unlimited) — paying + SaaS orgs can have this set to NULL to bypass quota enforcement. + """ user = await self.db_session.scalar( select(User).where(User.id == UUID(user_id)) ) - if user is None or user.daily_conversation_limit is None: + if user is None: return configured_daily_limit() - return user.daily_conversation_limit + + # User-level override takes precedence. + if user.daily_conversation_limit is not None: + return user.daily_conversation_limit + + # Org-level override: NULL means inherit deployment default, + # -1 means exempt (unlimited, for paying SaaS orgs), + # any other integer is the org-specific limit. + if user.current_org_id is not None: + from storage.org import Org + + org = await self.db_session.scalar( + select(Org).where(Org.id == user.current_org_id) + ) + if org is not None and org.daily_conversation_limit is not None: + if org.daily_conversation_limit == -1: + return None # exempt — unlimited + return org.daily_conversation_limit + + return configured_daily_limit() @staticmethod def _next_reset_iso() -> str: diff --git a/enterprise/storage/org.py b/enterprise/storage/org.py index ed04e5b2e..400648730 100644 --- a/enterprise/storage/org.py +++ b/enterprise/storage/org.py @@ -69,6 +69,10 @@ class Org(Base): # Set by completed billing sessions or when positive org credits are detected. byor_export_enabled: Mapped[bool] = mapped_column(nullable=False, default=False) sandbox_grouping_strategy: Mapped[str | None] = mapped_column(String, nullable=True) + # NULL inherits the deployment-wide daily conversation limit. + # Set to -1 to exempt this org from daily conversation limits entirely + # (for paying SaaS customers). Any other integer is the org-specific limit. + daily_conversation_limit: Mapped[int | None] = mapped_column(nullable=True) # Encrypted column for LLM profiles (contains API keys) llm_profiles: Mapped[dict[str, Any] | None] = mapped_column( EncryptedJSON, nullable=True diff --git a/enterprise/tests/unit/test_daily_conversation_quota_service.py b/enterprise/tests/unit/test_daily_conversation_quota_service.py index 0a9615d0a..1f63d5a39 100644 --- a/enterprise/tests/unit/test_daily_conversation_quota_service.py +++ b/enterprise/tests/unit/test_daily_conversation_quota_service.py @@ -16,12 +16,13 @@ async def test_get_status_unlimited(): """When limit is None (unlimited), remaining is None and reset_at is next UTC midnight.""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=None), + SimpleNamespace(daily_conversation_limit=None, current_org_id=None), None, # no usage record for today ] - service = DailyConversationQuotaService(session) - result = await service.get_status(USER_ID) + with patch.dict('os.environ', {}, clear=True): + service = DailyConversationQuotaService(session) + result = await service.get_status(USER_ID) assert isinstance(result, QuotaStatus) assert result.daily_limit is None @@ -37,7 +38,7 @@ async def test_get_status_with_limit_and_usage(): # First scalar call: User lookup returns a user with limit 20 # Second scalar call: DailyConversationUsage lookup returns count 5 session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=20), + SimpleNamespace(daily_conversation_limit=20, current_org_id=None), SimpleNamespace(conversation_count=5), ] @@ -55,7 +56,7 @@ async def test_get_status_remaining_floor_zero(): """Remaining never goes below zero even if usage exceeds limit.""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=10), + SimpleNamespace(daily_conversation_limit=10, current_org_id=None), SimpleNamespace(conversation_count=12), ] @@ -72,7 +73,7 @@ async def test_get_status_no_usage_today(): """When no usage record exists for today, used_today is 0.""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=20), + SimpleNamespace(daily_conversation_limit=20, current_org_id=None), None, ] diff --git a/enterprise/tests/unit/test_org_level_quota.py b/enterprise/tests/unit/test_org_level_quota.py new file mode 100644 index 000000000..7ce5e6855 --- /dev/null +++ b/enterprise/tests/unit/test_org_level_quota.py @@ -0,0 +1,89 @@ +"""Tests for org-level daily conversation quota resolution.""" + +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from server.services.daily_conversation_quota_service import ( + DailyConversationQuotaService, +) + +USER_ID = str(uuid4()) +ORG_ID = uuid4() + + +@pytest.mark.asyncio +async def test_user_override_takes_precedence(): + """User-level override wins over org and env.""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=50, current_org_id=ORG_ID), + SimpleNamespace(daily_conversation_limit=100), # org + ] + service = DailyConversationQuotaService(session) + assert await service.get_limit(USER_ID) == 50 + + +@pytest.mark.asyncio +async def test_org_override_when_user_is_null(): + """When user override is NULL, org override applies.""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=None, current_org_id=ORG_ID), + SimpleNamespace(daily_conversation_limit=100), # org + ] + service = DailyConversationQuotaService(session) + assert await service.get_limit(USER_ID) == 100 + + +@pytest.mark.asyncio +async def test_org_exempt_means_unlimited(): + """When org limit is -1, the user is exempt (returns None = unlimited).""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=None, current_org_id=ORG_ID), + SimpleNamespace(daily_conversation_limit=-1), # exempt + ] + service = DailyConversationQuotaService(session) + assert await service.get_limit(USER_ID) is None + + +@pytest.mark.asyncio +async def test_falls_back_to_env_when_both_null(): + """When both user and org overrides are NULL, env default applies.""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=None, current_org_id=ORG_ID), + SimpleNamespace(daily_conversation_limit=None), # not set + ] + with patch.dict('os.environ', {'OH_DAILY_CONVERSATION_LIMIT': '20'}): + service = DailyConversationQuotaService(session) + assert await service.get_limit(USER_ID) == 20 + + +@pytest.mark.asyncio +async def test_falls_back_to_none_when_unset(): + """When everything is unset, returns None (unlimited).""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=None, current_org_id=ORG_ID), + SimpleNamespace(daily_conversation_limit=None), + ] + with patch.dict('os.environ', {}, clear=True): + service = DailyConversationQuotaService(session) + assert await service.get_limit(USER_ID) is None + + +@pytest.mark.asyncio +async def test_user_null_org_exempt_takes_precedence_over_env(): + """Org exemption (-1) takes precedence over env default.""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=None, current_org_id=ORG_ID), + SimpleNamespace(daily_conversation_limit=-1), # exempt + ] + with patch.dict('os.environ', {'OH_DAILY_CONVERSATION_LIMIT': '20'}): + service = DailyConversationQuotaService(session) + assert await service.get_limit(USER_ID) is None From d06fa3a10e76cb1db3926dabda1d7a609ce20921 Mon Sep 17 00:00:00 2001 From: hieptl Date: Thu, 20 Aug 2026 22:32:42 +0700 Subject: [PATCH 06/13] fix: renumber quota migration to 150 and drop the user-limit backfill Main advanced through migrations 148/149 (budget changes), so this branch's migration reused the already-applied revision id 148. Renumber it to 150 on top of main's 149. Also drop the OH_DAILY_CONVERSATION_LIMIT backfill: stamping the deployment default into every existing user's daily_conversation_limit would take precedence over org-level limits/exemptions and future default changes, permanently pinning those users. NULL now always means 'inherit the effective default at runtime'. --- ...py => 150_add_daily_conversation_limit.py} | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) rename enterprise/migrations/versions/{148_add_daily_conversation_limit.py => 150_add_daily_conversation_limit.py} (66%) diff --git a/enterprise/migrations/versions/148_add_daily_conversation_limit.py b/enterprise/migrations/versions/150_add_daily_conversation_limit.py similarity index 66% rename from enterprise/migrations/versions/148_add_daily_conversation_limit.py rename to enterprise/migrations/versions/150_add_daily_conversation_limit.py index 48fa9f250..c103d02f4 100644 --- a/enterprise/migrations/versions/148_add_daily_conversation_limit.py +++ b/enterprise/migrations/versions/150_add_daily_conversation_limit.py @@ -1,13 +1,12 @@ """Add per-user daily conversation limits and usage accounting.""" -import os from typing import Sequence, Union import sqlalchemy as sa from alembic import op -revision: str = '148' -down_revision: Union[str, None] = '147' +revision: str = '150' +down_revision: Union[str, None] = '149' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -17,24 +16,16 @@ def upgrade() -> None: if bind.dialect.name != 'postgresql': raise RuntimeError(f'Unsupported database dialect: {bind.dialect.name}') + # NULL means "inherit the effective default at runtime" (org-level limit or + # the OH_DAILY_CONVERSATION_LIMIT deployment default). Existing users are + # deliberately NOT stamped with the deployment default here: a non-NULL + # user-level value takes precedence over org-level exemptions and future + # chart changes, so stamping would permanently pin every existing user. op.add_column( 'user', sa.Column('daily_conversation_limit', sa.Integer(), nullable=True), ) - # Persist the deployed default for existing users during the first rollout. - # Keeping NULL when the enterprise default is unset preserves unlimited mode - # and lets future chart changes take effect for users without an override. - raw_default = os.getenv('OH_DAILY_CONVERSATION_LIMIT', '').strip() - if raw_default: - op.execute( - sa.text( - 'UPDATE "user" SET daily_conversation_limit = :limit ' - 'WHERE daily_conversation_limit IS NULL' - ), - {'limit': int(raw_default)}, - ) - op.create_table( 'daily_conversation_usage', sa.Column('id', sa.Integer(), primary_key=True), From 80433f4fcfa238ce482e1231ac3d13fdcbf4e1ac Mon Sep 17 00:00:00 2001 From: hieptl Date: Thu, 20 Aug 2026 22:36:14 +0700 Subject: [PATCH 07/13] fix: resolve frontend lint and translation errors on quota page Return undefined explicitly from the countdown effect's no-op branch (consistent-return alongside the interval cleanup return), apply Prettier formatting to the SaaS-only message and countdown span, and swap the unsupported pt-BR translations of the quota keys for the required Catalan (ca) entries. --- frontend/src/i18n/translation.json | 14 +++++++------- frontend/src/routes/quota-settings.tsx | 11 ++++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 9a1a752c3..089e4f9ba 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -29220,7 +29220,7 @@ "tr": "Kota", "de": "Kontingent", "uk": "Квота", - "pt-BR": "Cota" + "ca": "Quota" }, "SETTINGS$QUOTA_DAILY_LIMIT": { "en": "Daily limit", @@ -29237,7 +29237,7 @@ "tr": "Günlük limit", "de": "Tageslimit", "uk": "Денний ліміт", - "pt-BR": "Limite diário" + "ca": "Límit diari" }, "SETTINGS$QUOTA_USED_TODAY": { "en": "Used today", @@ -29254,7 +29254,7 @@ "tr": "Bugün kullanılan", "de": "Heute verwendet", "uk": "Використано сьогодні", - "pt-BR": "Usado hoje" + "ca": "Utilitzat avui" }, "SETTINGS$QUOTA_REMAINING": { "en": "Remaining", @@ -29271,7 +29271,7 @@ "tr": "Kalan", "de": "Verbleibend", "uk": "Залишилось", - "pt-BR": "Restante" + "ca": "Restant" }, "SETTINGS$QUOTA_UNLIMITED": { "en": "Unlimited", @@ -29288,7 +29288,7 @@ "tr": "Sınırsız", "de": "Unbegrenzt", "uk": "Безлімітний", - "pt-BR": "Ilimitado" + "ca": "Il·limitat" }, "SETTINGS$QUOTA_RESETS_IN": { "en": "Resets in", @@ -29305,7 +29305,7 @@ "tr": "Sıfırlanmaya", "de": "Zurücksetzen in", "uk": "Скидається через", - "pt-BR": "Redefinir em" + "ca": "Es restableix en" }, "SETTINGS$QUOTA_SALES_ONLY": { "en": "Quota management is only available in SaaS mode.", @@ -29322,6 +29322,6 @@ "tr": "Kota yönetimi yalnızca SaaS modunda kullanılabilir.", "de": "Kontingentverwaltung ist nur im SaaS-Modus verfügbar.", "uk": "Управління квотою доступне лише в режимі SaaS.", - "pt-BR": "O gerenciamento de cota está disponível apenas no modo SaaS." + "ca": "La gestió de quotes només està disponible en mode SaaS." } } diff --git a/frontend/src/routes/quota-settings.tsx b/frontend/src/routes/quota-settings.tsx index bf8a9e4d9..3868a5b5e 100644 --- a/frontend/src/routes/quota-settings.tsx +++ b/frontend/src/routes/quota-settings.tsx @@ -10,7 +10,7 @@ function useCountdown(resetAt: string | null) { useEffect(() => { if (!resetAt) { setRemaining(""); - return; + return undefined; } const update = () => { @@ -46,9 +46,7 @@ function QuotaSettingsScreen() { if (!isSaas) { return (
-

- {t(I18nKey.SETTINGS$QUOTA_SALES_ONLY)} -

+

{t(I18nKey.SETTINGS$QUOTA_SALES_ONLY)}

); } @@ -126,7 +124,10 @@ function QuotaSettingsScreen() { data-testid="quota-reset-countdown" > {t(I18nKey.SETTINGS$QUOTA_RESETS_IN)} - + {countdown}
From 66163a3c1a601af002db5e69f4f13eccb91167a8 Mon Sep 17 00:00:00 2001 From: hieptl Date: Thu, 20 Aug 2026 22:36:57 +0700 Subject: [PATCH 08/13] fix: renumber org quota migration to 151 The revision id '149_org_quota' broke the numeric-prefix/revision match rule and, together with the quota-request migration, created a second Alembic head off revision 148. Renumber to a linear 151 on top of the renumbered 150 so 'alembic upgrade head' resolves a single head. --- ...ation_limit.py => 151_add_org_daily_conversation_limit.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename enterprise/migrations/versions/{149_org_quota_add_org_daily_conversation_limit.py => 151_add_org_daily_conversation_limit.py} (89%) diff --git a/enterprise/migrations/versions/149_org_quota_add_org_daily_conversation_limit.py b/enterprise/migrations/versions/151_add_org_daily_conversation_limit.py similarity index 89% rename from enterprise/migrations/versions/149_org_quota_add_org_daily_conversation_limit.py rename to enterprise/migrations/versions/151_add_org_daily_conversation_limit.py index e96562ab4..db7243340 100644 --- a/enterprise/migrations/versions/149_org_quota_add_org_daily_conversation_limit.py +++ b/enterprise/migrations/versions/151_add_org_daily_conversation_limit.py @@ -5,8 +5,8 @@ import sqlalchemy as sa from alembic import op -revision: str = '149_org_quota' -down_revision: Union[str, None] = '148' +revision: str = '151' +down_revision: Union[str, None] = '150' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None From 1347c21aeb5dfae58df500ae3ea151b231e133df Mon Sep 17 00:00:00 2001 From: hieptl Date: Thu, 20 Aug 2026 22:52:52 +0700 Subject: [PATCH 09/13] fix: use single quotes in daily usage storage test The enterprise ruff config enforces single-quoted strings; this literal was the one remaining violation failing the enterprise lint job. --- enterprise/tests/unit/storage/test_daily_conversation_usage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/enterprise/tests/unit/storage/test_daily_conversation_usage.py b/enterprise/tests/unit/storage/test_daily_conversation_usage.py index d474769bb..d355bfacb 100644 --- a/enterprise/tests/unit/storage/test_daily_conversation_usage.py +++ b/enterprise/tests/unit/storage/test_daily_conversation_usage.py @@ -7,7 +7,7 @@ def test_user_has_nullable_daily_conversation_limit(engine): column = next( column for column in inspect(engine).get_columns(User.__tablename__) - if column['name'] == "daily_conversation_limit" + if column['name'] == 'daily_conversation_limit' ) assert column['nullable'] is True From 7b3dc53a4f0b1b092fbff7548e7dcd356889bfc5 Mon Sep 17 00:00:00 2001 From: hieptl Date: Thu, 20 Aug 2026 23:08:44 +0700 Subject: [PATCH 10/13] fix: satisfy enterprise ruff on quota status files Sort the quota router imports, drop the unused AsyncSession and patch imports. These were previously masked in CI because the enterprise lint job failed to build its mypy environment before ruff could report. --- enterprise/saas_server.py | 2 +- enterprise/server/routes/quota.py | 3 +-- enterprise/tests/unit/test_daily_conversation_quota_service.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/enterprise/saas_server.py b/enterprise/saas_server.py index c731ecb6e..9443f147b 100644 --- a/enterprise/saas_server.py +++ b/enterprise/saas_server.py @@ -51,9 +51,9 @@ from server.routes.org_invitations import ( # noqa: E402 invitation_router, ) -from server.routes.quota import quota_router # noqa: E402 from server.routes.org_profiles import router as org_profiles_router # noqa: E402 from server.routes.orgs import org_router # noqa: E402 +from server.routes.quota import quota_router # noqa: E402 from server.routes.readiness import readiness_router # noqa: E402 from server.routes.service import service_router # noqa: E402 from server.routes.super_admins import super_admin_router # noqa: E402 diff --git a/enterprise/server/routes/quota.py b/enterprise/server/routes/quota.py index 652dc35dc..9d431705d 100644 --- a/enterprise/server/routes/quota.py +++ b/enterprise/server/routes/quota.py @@ -5,11 +5,10 @@ from server.services.daily_conversation_quota_service import ( DailyConversationQuotaService, ) -from sqlalchemy.ext.asyncio import AsyncSession +from storage.database import a_session_maker from openhands.app_server.user_auth import get_user_id from openhands.app_server.utils.dependencies import get_dependencies -from storage.database import a_session_maker quota_router = APIRouter( prefix='/api/quota', tags=['Quota'], dependencies=get_dependencies() diff --git a/enterprise/tests/unit/test_daily_conversation_quota_service.py b/enterprise/tests/unit/test_daily_conversation_quota_service.py index 0a9615d0a..598c08843 100644 --- a/enterprise/tests/unit/test_daily_conversation_quota_service.py +++ b/enterprise/tests/unit/test_daily_conversation_quota_service.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from uuid import uuid4 import pytest From a2f3f202dd441bcdcc7ec6be48b888da2f26e9b6 Mon Sep 17 00:00:00 2001 From: hieptl Date: Thu, 20 Aug 2026 23:11:58 +0700 Subject: [PATCH 11/13] fix: satisfy enterprise ruff on org quota files Drop the unused logger import and apply ruff formatting in the quota routes and org quota test; restore the patch import the upstream merge removed while this branch's env-clearing test still uses it. These were previously masked in CI by the enterprise lint job's environment failure. --- enterprise/server/routes/quota.py | 9 ++------- .../tests/unit/test_daily_conversation_quota_service.py | 2 +- enterprise/tests/unit/test_org_level_quota.py | 1 - 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/enterprise/server/routes/quota.py b/enterprise/server/routes/quota.py index 73a38121b..a90cec856 100644 --- a/enterprise/server/routes/quota.py +++ b/enterprise/server/routes/quota.py @@ -10,7 +10,6 @@ from openhands.app_server.user_auth import get_user_id from openhands.app_server.utils.dependencies import get_dependencies -from openhands.app_server.utils.logger import openhands_logger as logger quota_router = APIRouter( prefix='/api/quota', tags=['Quota'], dependencies=get_dependencies() @@ -67,9 +66,7 @@ async def _require_admin(user_id: str) -> None: ) -@quota_admin_router.put( - '/orgs/{org_id}/quota', response_model=OrgQuotaResponse -) +@quota_admin_router.put('/orgs/{org_id}/quota', response_model=OrgQuotaResponse) async def set_org_quota( org_id: str, body: OrgQuotaUpdateRequest, @@ -87,9 +84,7 @@ async def set_org_quota( from storage.org import Org async with a_session_maker() as session: - org = await session.scalar( - select(Org).where(Org.id == UUID(org_id)) - ) + org = await session.scalar(select(Org).where(Org.id == UUID(org_id))) if org is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/enterprise/tests/unit/test_daily_conversation_quota_service.py b/enterprise/tests/unit/test_daily_conversation_quota_service.py index 5ecd7c2fe..1f63d5a39 100644 --- a/enterprise/tests/unit/test_daily_conversation_quota_service.py +++ b/enterprise/tests/unit/test_daily_conversation_quota_service.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest diff --git a/enterprise/tests/unit/test_org_level_quota.py b/enterprise/tests/unit/test_org_level_quota.py index 7ce5e6855..053550804 100644 --- a/enterprise/tests/unit/test_org_level_quota.py +++ b/enterprise/tests/unit/test_org_level_quota.py @@ -1,6 +1,5 @@ """Tests for org-level daily conversation quota resolution.""" -import os from types import SimpleNamespace from unittest.mock import AsyncMock, patch from uuid import uuid4 From e3a975e728a88a19d88a57b6d02bda19f5a47a04 Mon Sep 17 00:00:00 2001 From: hieptl Date: Tue, 25 Aug 2026 00:25:12 +0700 Subject: [PATCH 12/13] fix: address review findings on org-level quota exemptions Scope quota resolution to the request's effective org, gate the admin API through the shared permission check, validate limit values, and guard the migration revision graph. - Resolve the daily conversation limit against the effective org (X-Org-Id / API-key binding) instead of user.current_org_id. The latter is only the user's last-selected org, so a multi-org user got the wrong org's limit -- and the wrong org's exemption -- whenever a request was scoped elsewhere. - Replace the hand-rolled super-role check on PUT /api/admin/quota/orgs/{org_id}/quota with require_permission(MANAGE_ORG_QUOTA). The inline check skipped the API-key organization binding, letting a key bound to one org edit another org's quota. MANAGE_ORG_QUOTA is granted only to the superadmin super role; no org-scoped role carries it. - Reject 0 and values below -1 on the admin API. They are not meaningful quotas but resolve to a limit no org can satisfy, silently blocking every member -- a mistyped "-11" for "-1" would have done the opposite of the intended exemption. Treat -1 as "exempt" at the user level too so the sentinel means the same thing at both levels. - Type the path org_id as UUID so a malformed id is a 422, not a 500. - Add test_migration_graph.py: duplicate revision ids, multiple heads, missing parents and shared parents now fail loudly. Sequential numbering means concurrent branches pick the same number and each passes CI alone, so the collision otherwise only surfaces on main after the second merge. - Cover the admin route and its authorization gate, and pin the real user -> org -> usage query sequence in get_status. --- enterprise/server/auth/authorization.py | 10 +- enterprise/server/routes/quota.py | 90 ++++--- .../daily_conversation_quota_service.py | 60 +++-- .../unit/server/routes/test_quota_admin.py | 236 ++++++++++++++++++ .../unit/server/routes/test_quota_status.py | 98 ++++++++ enterprise/tests/unit/test_authorization.py | 18 ++ .../test_daily_conversation_quota_service.py | 60 ++++- enterprise/tests/unit/test_migration_graph.py | 91 +++++++ enterprise/tests/unit/test_org_level_quota.py | 82 ++++-- 9 files changed, 663 insertions(+), 82 deletions(-) create mode 100644 enterprise/tests/unit/server/routes/test_quota_admin.py create mode 100644 enterprise/tests/unit/server/routes/test_quota_status.py create mode 100644 enterprise/tests/unit/test_migration_graph.py diff --git a/enterprise/server/auth/authorization.py b/enterprise/server/auth/authorization.py index 7fae2d363..cd1b9722e 100644 --- a/enterprise/server/auth/authorization.py +++ b/enterprise/server/auth/authorization.py @@ -107,6 +107,12 @@ class Permission(str, Enum): # granted only to the ``superadmin`` super role. MANAGE_SUPER_ADMINS = 'manage_super_admins' + # Instance-level quota administration: set or clear an organization's + # daily conversation limit. Like MANAGE_SUPER_ADMINS this is an explicit + # instance-admin capability -- it is NOT implied by any org-scoped role, + # so an org owner cannot lift their own org's quota. + MANAGE_ORG_QUOTA = 'manage_org_quota' + class RoleName(str, Enum): """Role names used in the system. @@ -233,7 +239,8 @@ def super_role_name(role_name: str) -> str: SUPER_ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = { # Only superadmin is functional for now. It can create organizations, # provision users into a selected organization without becoming an org - # member itself, and grant/revoke the super-admin role on other users. + # member itself, grant/revoke the super-admin role on other users, and + # set org-level conversation quotas. # Additional instance-admin capabilities should be added here explicitly # as the corresponding routes are wired to permission checks. RoleName.OWNER: frozenset(), @@ -242,6 +249,7 @@ def super_role_name(role_name: str) -> str: Permission.CREATE_ORGANIZATION, Permission.PROVISION_USER, Permission.MANAGE_SUPER_ADMINS, + Permission.MANAGE_ORG_QUOTA, ] ), RoleName.MEMBER: frozenset(), diff --git a/enterprise/server/routes/quota.py b/enterprise/server/routes/quota.py index a90cec856..1371297f4 100644 --- a/enterprise/server/routes/quota.py +++ b/enterprise/server/routes/quota.py @@ -1,12 +1,18 @@ """Quota status and org-level quota management API.""" +from uuid import UUID + from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator +from server.auth.authorization import Permission, require_permission +from server.auth.org_context import EFFECTIVE_ORG_ID, REJECT_X_ORG_ID_PATH_MISMATCH from server.services.daily_conversation_quota_service import ( + EXEMPT_LIMIT, DailyConversationQuotaService, ) from sqlalchemy import select from storage.database import a_session_maker +from storage.org import Org from openhands.app_server.user_auth import get_user_id from openhands.app_server.utils.dependencies import get_dependencies @@ -24,28 +30,61 @@ class QuotaStatusResponse(BaseModel): @quota_router.get('/status', response_model=QuotaStatusResponse) -async def get_quota_status(user_id: str = Depends(get_user_id)) -> QuotaStatusResponse: - """Return the authenticated user's daily conversation quota status.""" +async def get_quota_status( + user_id: str = Depends(get_user_id), + effective_org_id: UUID = EFFECTIVE_ORG_ID, +) -> QuotaStatusResponse: + """Return the authenticated user's daily conversation quota status. + + Scoped to the request's effective org (``X-Org-Id`` / API-key binding), + so a user in several orgs sees the quota of the org they are actually + working in. + """ async with a_session_maker() as session: service = DailyConversationQuotaService(session) - status = await service.get_status(user_id) + quota = await service.get_status(user_id, effective_org_id) return QuotaStatusResponse( - daily_limit=status.daily_limit, - used_today=status.used_today, - remaining=status.remaining, - reset_at=status.reset_at, + daily_limit=quota.daily_limit, + used_today=quota.used_today, + remaining=quota.remaining, + reset_at=quota.reset_at, ) # --- Org-level quota management (admin) --- quota_admin_router = APIRouter( - prefix='/api/admin/quota', tags=['Admin'], dependencies=get_dependencies() + prefix='/api/admin/quota', + tags=['Admin'], + dependencies=[*get_dependencies(), REJECT_X_ORG_ID_PATH_MISMATCH], ) class OrgQuotaUpdateRequest(BaseModel): - daily_conversation_limit: int | None + daily_conversation_limit: int | None = Field( + description=( + 'NULL to inherit the deployment default, -1 to exempt the org ' + 'entirely, or a positive integer for an org-specific limit.' + ), + ) + + @field_validator('daily_conversation_limit') + @classmethod + def _reject_meaningless_limits(cls, value: int | None) -> int | None: + """Allow only NULL, the exemption sentinel, and positive limits. + + 0 and values below -1 are rejected rather than stored: they are not + meaningful quotas, but they resolve to a limit the org can never + satisfy, silently blocking every member with no error anywhere. A + mistyped '-11' for '-1' would otherwise do the exact opposite of the + intended exemption. + """ + if value is None or value == EXEMPT_LIMIT or value > 0: + return value + raise ValueError( + f'daily_conversation_limit must be null (inherit), {EXEMPT_LIMIT} ' + f'(exempt), or a positive integer; got {value}' + ) class OrgQuotaResponse(BaseModel): @@ -54,37 +93,26 @@ class OrgQuotaResponse(BaseModel): daily_conversation_limit: int | None -async def _require_admin(user_id: str) -> None: - """Check that the user is an admin (has superadmin role).""" - from server.auth.authorization import get_user_super_role - - role = await get_user_super_role(user_id) - if role is None or role.name != 'admin': - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail='Admin access required', - ) - - @quota_admin_router.put('/orgs/{org_id}/quota', response_model=OrgQuotaResponse) async def set_org_quota( - org_id: str, + org_id: UUID, body: OrgQuotaUpdateRequest, - user_id: str = Depends(get_user_id), + _: str = Depends(require_permission(Permission.MANAGE_ORG_QUOTA)), ) -> OrgQuotaResponse: """Set or clear an org-level daily conversation limit override. - Admin only. Set to -1 to exempt the org entirely (unlimited). + Requires the instance-level ``MANAGE_ORG_QUOTA`` permission, which is + granted only to the superadmin super role. Going through + ``require_permission`` (rather than checking the super role inline) also + enforces the API-key organization binding, so a key bound to one org + cannot edit another org's quota. + + Set to -1 to exempt the org entirely (unlimited). Set to NULL to inherit the deployment default. Set to a positive integer for an org-specific limit. """ - await _require_admin(user_id) - from uuid import UUID - - from storage.org import Org - async with a_session_maker() as session: - org = await session.scalar(select(Org).where(Org.id == UUID(org_id))) + org = await session.scalar(select(Org).where(Org.id == org_id)) if org is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/enterprise/server/services/daily_conversation_quota_service.py b/enterprise/server/services/daily_conversation_quota_service.py index d7b1f7894..722ed4032 100644 --- a/enterprise/server/services/daily_conversation_quota_service.py +++ b/enterprise/server/services/daily_conversation_quota_service.py @@ -10,10 +10,17 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from storage.daily_conversation_usage import DailyConversationUsage +from storage.org import Org from storage.user import User DEFAULT_ENV_VAR = 'OH_DAILY_CONVERSATION_LIMIT' +# Sentinel stored in ``user.daily_conversation_limit`` / +# ``org.daily_conversation_limit`` meaning "exempt -- no limit at all". +# NULL cannot carry that meaning because NULL already means "inherit from +# the next level down". +EXEMPT_LIMIT = -1 + def configured_daily_limit() -> int | None: """Read the deployment default; unset and blank mean unlimited.""" @@ -38,8 +45,8 @@ class DailyConversationQuotaService: def __init__(self, db_session: AsyncSession) -> None: self.db_session = db_session - async def get_status(self, user_id: str) -> QuotaStatus: - limit = await self.get_limit(user_id) + async def get_status(self, user_id: str, org_id: UUID) -> QuotaStatus: + limit = await self.get_limit(user_id, org_id) used = await self._used(user_id, datetime.now(UTC).date()) remaining = None if limit is None else max(limit - used, 0) reset_at = self._next_reset_iso() @@ -50,44 +57,43 @@ async def get_status(self, user_id: str) -> QuotaStatus: reset_at=reset_at, ) - async def get_limit(self, user_id: str) -> int | None: - """Resolve the effective daily conversation limit. + async def get_limit(self, user_id: str, org_id: UUID) -> int | None: + """Resolve the effective daily conversation limit, or None for unlimited. + + Precedence (first non-NULL level wins): - Precedence (first non-None wins): 1. User-level override (``user.daily_conversation_limit``) 2. Org-level override (``org.daily_conversation_limit``) 3. Deployment default (``OH_DAILY_CONVERSATION_LIMIT`` env var) - NULL at any level means "inherit from the next level down." - A NULL org override means the org is exempt (unlimited) — paying - SaaS orgs can have this set to NULL to bypass quota enforcement. + NULL at a level means "inherit from the next level down"; it never + means exempt. To exempt (e.g. a paying SaaS org) store + ``EXEMPT_LIMIT`` (-1), which resolves to None at either level. + + ``org_id`` must be the request's *effective* org -- resolved via + ``EFFECTIVE_ORG_ID`` so the API-key binding and ``X-Org-Id`` header + are honored. It is deliberately not derived from + ``user.current_org_id`` here: that is only the user's last-selected + org and would apply the wrong org's quota (or the wrong org's + exemption) whenever the request is scoped to a different one. """ user = await self.db_session.scalar( select(User).where(User.id == UUID(user_id)) ) - if user is None: - return configured_daily_limit() - - # User-level override takes precedence. - if user.daily_conversation_limit is not None: - return user.daily_conversation_limit + if user is not None and user.daily_conversation_limit is not None: + return self._resolve_sentinel(user.daily_conversation_limit) - # Org-level override: NULL means inherit deployment default, - # -1 means exempt (unlimited, for paying SaaS orgs), - # any other integer is the org-specific limit. - if user.current_org_id is not None: - from storage.org import Org - - org = await self.db_session.scalar( - select(Org).where(Org.id == user.current_org_id) - ) - if org is not None and org.daily_conversation_limit is not None: - if org.daily_conversation_limit == -1: - return None # exempt — unlimited - return org.daily_conversation_limit + org = await self.db_session.scalar(select(Org).where(Org.id == org_id)) + if org is not None and org.daily_conversation_limit is not None: + return self._resolve_sentinel(org.daily_conversation_limit) return configured_daily_limit() + @staticmethod + def _resolve_sentinel(limit: int) -> int | None: + """Translate the stored exemption sentinel into "unlimited".""" + return None if limit == EXEMPT_LIMIT else limit + @staticmethod def _next_reset_iso() -> str: """ISO timestamp of the next UTC midnight.""" diff --git a/enterprise/tests/unit/server/routes/test_quota_admin.py b/enterprise/tests/unit/server/routes/test_quota_admin.py new file mode 100644 index 000000000..3e3a9be8a --- /dev/null +++ b/enterprise/tests/unit/server/routes/test_quota_admin.py @@ -0,0 +1,236 @@ +"""Route-level tests for the org quota administration API. + +These exercise the FastAPI route through ``require_permission`` and stub +the DB session at the route module boundary. Authorization is faked the +same way ``test_super_admins.py`` does: short-circuit the org-role lookup +to ``None`` and stack a ``superadmin`` super role on top of the +conftest-level ``get_user_super_role -> None`` default. +""" + +import uuid +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from server.routes.quota import quota_admin_router + +from openhands.app_server.user_auth import get_user_id + +CALLER_USER_ID = str(uuid.uuid4()) +ORG_ID = uuid.uuid4() + + +@pytest.fixture +def mock_app(): + app = FastAPI() + app.include_router(quota_admin_router) + app.dependency_overrides[get_user_id] = lambda: CALLER_USER_ID + return app + + +@pytest.fixture +def grant_manage_org_quota(): + """Make ``MANAGE_ORG_QUOTA`` succeed by faking a ``superadmin`` role.""" + superadmin = MagicMock() + superadmin.name = 'admin' + with ( + patch( + 'server.auth.authorization.get_user_org_role', + AsyncMock(return_value=None), + ), + patch( + 'server.auth.authorization.get_user_super_role', + AsyncMock(return_value=superadmin), + ), + ): + yield + + +@pytest.fixture +def deny_manage_org_quota(): + """Caller is an org owner but holds no super role.""" + owner = MagicMock() + owner.name = 'owner' + with patch( + 'server.auth.authorization.get_user_org_role', + AsyncMock(return_value=owner), + ): + yield + + +def _client(app): + return AsyncClient(transport=ASGITransport(app=app), base_url='http://test') + + +def _fake_org(limit: int | None = None): + org = MagicMock() + org.id = ORG_ID + org.name = 'Acme' + org.daily_conversation_limit = limit + return org + + +@asynccontextmanager +async def _session_yielding(org): + session = AsyncMock() + session.scalar = AsyncMock(return_value=org) + yield session + + +def _patch_session(org): + return patch( + 'server.routes.quota.a_session_maker', + lambda **kwargs: _session_yielding(org), + ) + + +def _url(org_id=ORG_ID): + return f'/api/admin/quota/orgs/{org_id}/quota' + + +@pytest.mark.asyncio +async def test_superadmin_can_set_org_limit(mock_app, grant_manage_org_quota): + org = _fake_org() + with _patch_session(org): + async with _client(mock_app) as client: + resp = await client.put(_url(), json={'daily_conversation_limit': 25}) + + assert resp.status_code == 200 + assert resp.json() == { + 'org_id': str(ORG_ID), + 'org_name': 'Acme', + 'daily_conversation_limit': 25, + } + assert org.daily_conversation_limit == 25 + + +@pytest.mark.asyncio +async def test_superadmin_can_exempt_org(mock_app, grant_manage_org_quota): + org = _fake_org(limit=10) + with _patch_session(org): + async with _client(mock_app) as client: + resp = await client.put(_url(), json={'daily_conversation_limit': -1}) + + assert resp.status_code == 200 + assert resp.json()['daily_conversation_limit'] == -1 + + +@pytest.mark.asyncio +async def test_superadmin_can_clear_override(mock_app, grant_manage_org_quota): + org = _fake_org(limit=10) + with _patch_session(org): + async with _client(mock_app) as client: + resp = await client.put(_url(), json={'daily_conversation_limit': None}) + + assert resp.status_code == 200 + assert resp.json()['daily_conversation_limit'] is None + + +@pytest.mark.asyncio +async def test_unknown_org_returns_404(mock_app, grant_manage_org_quota): + with _patch_session(None): + async with _client(mock_app) as client: + resp = await client.put(_url(), json={'daily_conversation_limit': 5}) + + assert resp.status_code == 404 + assert resp.json()['detail'] == 'Organization not found' + + +@pytest.mark.asyncio +async def test_org_owner_without_super_role_is_denied(mock_app, deny_manage_org_quota): + """MANAGE_ORG_QUOTA is instance-level: no org-scoped role grants it.""" + org = _fake_org() + with _patch_session(org): + async with _client(mock_app) as client: + resp = await client.put(_url(), json={'daily_conversation_limit': 5}) + + assert resp.status_code == 403 + assert org.daily_conversation_limit is None + + +@pytest.mark.asyncio +async def test_org_bound_api_key_cannot_edit_another_org( + mock_app, grant_manage_org_quota +): + """An API key bound to one org must not reach another org's quota. + + Regression guard for the hand-rolled super-role check this route used + to carry, which skipped ``require_permission``'s API-key organization + binding entirely. + """ + other_org = uuid.uuid4() + org = _fake_org() + with ( + patch( + 'server.auth.authorization.get_api_key_org_id_from_request', + AsyncMock(return_value=other_org), + ), + _patch_session(org), + ): + async with _client(mock_app) as client: + resp = await client.put(_url(), json={'daily_conversation_limit': 5}) + + assert resp.status_code == 403 + assert 'not authorized for this organization' in resp.json()['detail'] + assert org.daily_conversation_limit is None + + +@pytest.mark.asyncio +async def test_org_bound_api_key_can_edit_its_own_org(mock_app, grant_manage_org_quota): + org = _fake_org() + with ( + patch( + 'server.auth.authorization.get_api_key_org_id_from_request', + AsyncMock(return_value=ORG_ID), + ), + _patch_session(org), + ): + async with _client(mock_app) as client: + resp = await client.put(_url(), json={'daily_conversation_limit': 5}) + + assert resp.status_code == 200 + assert org.daily_conversation_limit == 5 + + +@pytest.mark.asyncio +@pytest.mark.parametrize('limit', [0, -2, -11, -100]) +async def test_meaningless_limits_are_rejected(mock_app, grant_manage_org_quota, limit): + """0 and values below -1 would silently hard-block the whole org.""" + org = _fake_org() + with _patch_session(org): + async with _client(mock_app) as client: + resp = await client.put(_url(), json={'daily_conversation_limit': limit}) + + assert resp.status_code == 422 + assert org.daily_conversation_limit is None + + +@pytest.mark.asyncio +async def test_malformed_org_id_is_rejected(mock_app, grant_manage_org_quota): + """A non-UUID org id is a 422, not an unhandled 500.""" + async with _client(mock_app) as client: + resp = await client.put( + _url('not-a-uuid'), json={'daily_conversation_limit': 5} + ) + + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_conflicting_x_org_id_header_is_rejected( + mock_app, grant_manage_org_quota +): + """The path pins the org; a contradictory X-Org-Id header is a 400.""" + org = _fake_org() + with _patch_session(org): + async with _client(mock_app) as client: + resp = await client.put( + _url(), + json={'daily_conversation_limit': 5}, + headers={'X-Org-Id': str(uuid.uuid4())}, + ) + + assert resp.status_code == 400 + assert org.daily_conversation_limit is None diff --git a/enterprise/tests/unit/server/routes/test_quota_status.py b/enterprise/tests/unit/server/routes/test_quota_status.py new file mode 100644 index 000000000..ee252ea0f --- /dev/null +++ b/enterprise/tests/unit/server/routes/test_quota_status.py @@ -0,0 +1,98 @@ +"""Route-level tests for the quota status endpoint. + +The point of interest is org scoping: the route must hand the service the +request's *effective* org (``X-Org-Id`` / API-key binding, resolved by +``EFFECTIVE_ORG_ID``) rather than letting the service fall back to the +user's last-selected ``current_org_id``. +""" + +import uuid +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from server.auth.org_context import resolve_effective_org_id +from server.routes.quota import quota_router +from server.services.daily_conversation_quota_service import QuotaStatus + +from openhands.app_server.user_auth import get_user_id + +CALLER_USER_ID = str(uuid.uuid4()) +EFFECTIVE_ORG = uuid.uuid4() + + +@asynccontextmanager +async def _session(): + yield AsyncMock() + + +@pytest.fixture +def mock_app(): + app = FastAPI() + app.include_router(quota_router) + app.dependency_overrides[get_user_id] = lambda: CALLER_USER_ID + app.dependency_overrides[resolve_effective_org_id] = lambda: EFFECTIVE_ORG + return app + + +def _client(app): + return AsyncClient(transport=ASGITransport(app=app), base_url='http://test') + + +@pytest.mark.asyncio +async def test_status_is_scoped_to_the_effective_org(mock_app): + status = QuotaStatus( + daily_limit=15, + used_today=3, + remaining=12, + reset_at='2026-08-26T00:00:00+00:00', + ) + get_status = AsyncMock(return_value=status) + + with ( + patch('server.routes.quota.a_session_maker', lambda **kwargs: _session()), + patch( + 'server.routes.quota.DailyConversationQuotaService.get_status', + get_status, + ), + ): + async with _client(mock_app) as client: + resp = await client.get('/api/quota/status') + + assert resp.status_code == 200 + assert resp.json() == { + 'daily_limit': 15, + 'used_today': 3, + 'remaining': 12, + 'reset_at': '2026-08-26T00:00:00+00:00', + } + # The effective org -- not the user's current_org_id -- reaches the service. + get_status.assert_awaited_once_with(CALLER_USER_ID, EFFECTIVE_ORG) + + +@pytest.mark.asyncio +async def test_status_reports_unlimited_without_a_limit(mock_app): + status = QuotaStatus( + daily_limit=None, + used_today=7, + remaining=None, + reset_at='2026-08-26T00:00:00+00:00', + ) + + with ( + patch('server.routes.quota.a_session_maker', lambda **kwargs: _session()), + patch( + 'server.routes.quota.DailyConversationQuotaService.get_status', + AsyncMock(return_value=status), + ), + ): + async with _client(mock_app) as client: + resp = await client.get('/api/quota/status') + + assert resp.status_code == 200 + body = resp.json() + assert body['daily_limit'] is None + assert body['remaining'] is None + assert body['used_today'] == 7 diff --git a/enterprise/tests/unit/test_authorization.py b/enterprise/tests/unit/test_authorization.py index 1c7d5cfd2..27c7d2e9c 100644 --- a/enterprise/tests/unit/test_authorization.py +++ b/enterprise/tests/unit/test_authorization.py @@ -1473,6 +1473,7 @@ def test_super_role_permissions_are_explicit(self): Permission.CREATE_ORGANIZATION, Permission.PROVISION_USER, Permission.MANAGE_SUPER_ADMINS, + Permission.MANAGE_ORG_QUOTA, ] ) assert SUPER_ROLE_PERMISSIONS[RoleName.MEMBER] == frozenset() @@ -1520,6 +1521,23 @@ def test_only_superadmin_grants_instance_org_management(self): assert Permission.PROVISION_USER in SUPER_ROLE_PERMISSIONS[RoleName.ADMIN] assert Permission.PROVISION_USER not in SUPER_ROLE_PERMISSIONS[RoleName.MEMBER] + def test_manage_org_quota_is_superadmin_only(self): + """ + GIVEN: SUPER_ROLE_PERMISSIONS and ROLE_PERMISSIONS + WHEN: looking up MANAGE_ORG_QUOTA + THEN: only the superadmin super role carries it -- no org-scoped + role does, so an org owner cannot lift their own org's quota. + """ + assert Permission.MANAGE_ORG_QUOTA in SUPER_ROLE_PERMISSIONS[RoleName.ADMIN] + assert Permission.MANAGE_ORG_QUOTA not in SUPER_ROLE_PERMISSIONS[RoleName.OWNER] + assert ( + Permission.MANAGE_ORG_QUOTA not in SUPER_ROLE_PERMISSIONS[RoleName.MEMBER] + ) + for role_name, permissions in ROLE_PERMISSIONS.items(): + assert Permission.MANAGE_ORG_QUOTA not in permissions, ( + f'org-scoped role {role_name} must not grant MANAGE_ORG_QUOTA' + ) + class TestHasPermissionSuper: """Tests for ``has_permission`` with the ``is_super`` flag.""" diff --git a/enterprise/tests/unit/test_daily_conversation_quota_service.py b/enterprise/tests/unit/test_daily_conversation_quota_service.py index 1f63d5a39..32348f071 100644 --- a/enterprise/tests/unit/test_daily_conversation_quota_service.py +++ b/enterprise/tests/unit/test_daily_conversation_quota_service.py @@ -9,6 +9,7 @@ ) USER_ID = str(uuid4()) +ORG_ID = uuid4() @pytest.mark.asyncio @@ -16,13 +17,14 @@ async def test_get_status_unlimited(): """When limit is None (unlimited), remaining is None and reset_at is next UTC midnight.""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=None, current_org_id=None), + SimpleNamespace(daily_conversation_limit=None), # user: no override + SimpleNamespace(daily_conversation_limit=None), # org: no override None, # no usage record for today ] with patch.dict('os.environ', {}, clear=True): service = DailyConversationQuotaService(session) - result = await service.get_status(USER_ID) + result = await service.get_status(USER_ID, ORG_ID) assert isinstance(result, QuotaStatus) assert result.daily_limit is None @@ -38,12 +40,12 @@ async def test_get_status_with_limit_and_usage(): # First scalar call: User lookup returns a user with limit 20 # Second scalar call: DailyConversationUsage lookup returns count 5 session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=20, current_org_id=None), + SimpleNamespace(daily_conversation_limit=20), SimpleNamespace(conversation_count=5), ] service = DailyConversationQuotaService(session) - result = await service.get_status(USER_ID) + result = await service.get_status(USER_ID, ORG_ID) assert result.daily_limit == 20 assert result.used_today == 5 @@ -56,12 +58,12 @@ async def test_get_status_remaining_floor_zero(): """Remaining never goes below zero even if usage exceeds limit.""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=10, current_org_id=None), + SimpleNamespace(daily_conversation_limit=10), SimpleNamespace(conversation_count=12), ] service = DailyConversationQuotaService(session) - result = await service.get_status(USER_ID) + result = await service.get_status(USER_ID, ORG_ID) assert result.daily_limit == 10 assert result.used_today == 12 @@ -73,13 +75,55 @@ async def test_get_status_no_usage_today(): """When no usage record exists for today, used_today is 0.""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=20, current_org_id=None), + SimpleNamespace(daily_conversation_limit=20), None, ] service = DailyConversationQuotaService(session) - result = await service.get_status(USER_ID) + result = await service.get_status(USER_ID, ORG_ID) assert result.daily_limit == 20 assert result.used_today == 0 assert result.remaining == 20 + + +@pytest.mark.asyncio +async def test_get_status_resolves_org_limit_through_full_query_sequence(): + """The production path: user override NULL -> org override -> usage. + + Pins the real three-query sequence (user, org, usage) that every + request takes, so an ordering regression in ``get_status`` is caught. + """ + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=None), # user: inherit + SimpleNamespace(daily_conversation_limit=30), # org: org-specific limit + SimpleNamespace(conversation_count=4), # usage today + ] + + service = DailyConversationQuotaService(session) + result = await service.get_status(USER_ID, ORG_ID) + + assert session.scalar.await_count == 3 + assert result.daily_limit == 30 + assert result.used_today == 4 + assert result.remaining == 26 + + +@pytest.mark.asyncio +async def test_get_status_org_exemption_reports_unlimited(): + """An exempt org (-1) surfaces as unlimited rather than a negative limit.""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=None), # user: inherit + SimpleNamespace(daily_conversation_limit=-1), # org: exempt + SimpleNamespace(conversation_count=99), # usage today + ] + + with patch.dict('os.environ', {'OH_DAILY_CONVERSATION_LIMIT': '20'}): + service = DailyConversationQuotaService(session) + result = await service.get_status(USER_ID, ORG_ID) + + assert result.daily_limit is None + assert result.used_today == 99 + assert result.remaining is None diff --git a/enterprise/tests/unit/test_migration_graph.py b/enterprise/tests/unit/test_migration_graph.py new file mode 100644 index 000000000..5536067c2 --- /dev/null +++ b/enterprise/tests/unit/test_migration_graph.py @@ -0,0 +1,91 @@ +"""Integrity checks for the Alembic revision graph. + +Migrations here are numbered sequentially, so two branches opened off the +same parent independently pick the *same* next number. Each branch is +valid on its own -- and passes CI on its own -- but the second one to +merge lands a duplicate revision id and a second head on ``main``, which +breaks ``alembic upgrade head`` for every deploy. + +These tests make that collision fail loudly on the second branch instead +of silently on ``main`` after the merge. +""" + +import re +from pathlib import Path + +VERSIONS_DIR = Path(__file__).resolve().parents[2] / 'migrations' / 'versions' + +_REVISION_RE = re.compile(r'^revision(?::\s*[^=]+)?\s*=\s*[\'"]([^\'"]+)[\'"]', re.M) +_DOWN_REVISION_RE = re.compile( + r'^down_revision(?::\s*[^=]+)?\s*=\s*(?:[\'"]([^\'"]+)[\'"]|None)', re.M +) + + +def _migrations() -> dict[str, tuple[str, str | None]]: + """Map each migration file to its ``(revision, down_revision)`` pair.""" + found: dict[str, tuple[str, str | None]] = {} + for path in sorted(VERSIONS_DIR.glob('*.py')): + if path.name == '__init__.py': + continue + source = path.read_text() + revision_match = _REVISION_RE.search(source) + down_match = _DOWN_REVISION_RE.search(source) + assert revision_match is not None, f'{path.name} declares no revision' + assert down_match is not None, f'{path.name} declares no down_revision' + found[path.name] = (revision_match.group(1), down_match.group(1)) + return found + + +def test_revision_ids_are_unique(): + """Two migrations must never claim the same revision id.""" + by_revision: dict[str, list[str]] = {} + for filename, (revision, _) in _migrations().items(): + by_revision.setdefault(revision, []).append(filename) + + duplicates = {rev: files for rev, files in by_revision.items() if len(files) > 1} + assert not duplicates, ( + 'Duplicate Alembic revision ids: ' + f'{duplicates}. Another branch already merged this number -- ' + 'renumber this migration to the next free revision.' + ) + + +def test_revision_graph_has_a_single_head(): + """Exactly one migration must be unreferenced as a parent.""" + migrations = _migrations() + revisions = {revision for revision, _ in migrations.values()} + parents = {down for _, down in migrations.values() if down is not None} + + heads = sorted(revisions - parents) + assert len(heads) == 1, ( + f'Expected exactly one Alembic head, found {len(heads)}: {heads}. ' + 'Concurrent migrations branched off the same parent -- rebase one ' + 'onto the other so the chain stays linear.' + ) + + +def test_every_down_revision_exists(): + """No migration may point at a parent that is not in the tree.""" + migrations = _migrations() + revisions = {revision for revision, _ in migrations.values()} + + orphans = { + filename: down + for filename, (_, down) in migrations.items() + if down is not None and down not in revisions + } + assert not orphans, f'Migrations reference missing parents: {orphans}' + + +def test_no_two_migrations_share_a_parent(): + """A shared parent is a fork in the chain, even if one head still wins.""" + by_parent: dict[str, list[str]] = {} + for filename, (_, down) in _migrations().items(): + if down is not None: + by_parent.setdefault(down, []).append(filename) + + forks = {down: files for down, files in by_parent.items() if len(files) > 1} + assert not forks, ( + f'Multiple migrations share a down_revision: {forks}. ' + 'Chain them sequentially instead of branching.' + ) diff --git a/enterprise/tests/unit/test_org_level_quota.py b/enterprise/tests/unit/test_org_level_quota.py index 053550804..7bce146d9 100644 --- a/enterprise/tests/unit/test_org_level_quota.py +++ b/enterprise/tests/unit/test_org_level_quota.py @@ -18,11 +18,13 @@ async def test_user_override_takes_precedence(): """User-level override wins over org and env.""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=50, current_org_id=ORG_ID), - SimpleNamespace(daily_conversation_limit=100), # org + SimpleNamespace(daily_conversation_limit=50), # user + SimpleNamespace(daily_conversation_limit=100), # org (never reached) ] service = DailyConversationQuotaService(session) - assert await service.get_limit(USER_ID) == 50 + assert await service.get_limit(USER_ID, ORG_ID) == 50 + # The org row is not even queried once the user override resolves. + assert session.scalar.await_count == 1 @pytest.mark.asyncio @@ -30,11 +32,11 @@ async def test_org_override_when_user_is_null(): """When user override is NULL, org override applies.""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=None, current_org_id=ORG_ID), + SimpleNamespace(daily_conversation_limit=None), # user SimpleNamespace(daily_conversation_limit=100), # org ] service = DailyConversationQuotaService(session) - assert await service.get_limit(USER_ID) == 100 + assert await service.get_limit(USER_ID, ORG_ID) == 100 @pytest.mark.asyncio @@ -42,11 +44,24 @@ async def test_org_exempt_means_unlimited(): """When org limit is -1, the user is exempt (returns None = unlimited).""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=None, current_org_id=ORG_ID), + SimpleNamespace(daily_conversation_limit=None), # user SimpleNamespace(daily_conversation_limit=-1), # exempt ] service = DailyConversationQuotaService(session) - assert await service.get_limit(USER_ID) is None + assert await service.get_limit(USER_ID, ORG_ID) is None + + +@pytest.mark.asyncio +async def test_user_exempt_means_unlimited(): + """-1 means exempt at the user level too, not a literal limit of -1.""" + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=-1), # user: exempt + SimpleNamespace(daily_conversation_limit=5), # org (never reached) + ] + with patch.dict('os.environ', {'OH_DAILY_CONVERSATION_LIMIT': '20'}): + service = DailyConversationQuotaService(session) + assert await service.get_limit(USER_ID, ORG_ID) is None @pytest.mark.asyncio @@ -54,12 +69,12 @@ async def test_falls_back_to_env_when_both_null(): """When both user and org overrides are NULL, env default applies.""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=None, current_org_id=ORG_ID), - SimpleNamespace(daily_conversation_limit=None), # not set + SimpleNamespace(daily_conversation_limit=None), # user + SimpleNamespace(daily_conversation_limit=None), # org: not set ] with patch.dict('os.environ', {'OH_DAILY_CONVERSATION_LIMIT': '20'}): service = DailyConversationQuotaService(session) - assert await service.get_limit(USER_ID) == 20 + assert await service.get_limit(USER_ID, ORG_ID) == 20 @pytest.mark.asyncio @@ -67,12 +82,12 @@ async def test_falls_back_to_none_when_unset(): """When everything is unset, returns None (unlimited).""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=None, current_org_id=ORG_ID), - SimpleNamespace(daily_conversation_limit=None), + SimpleNamespace(daily_conversation_limit=None), # user + SimpleNamespace(daily_conversation_limit=None), # org ] with patch.dict('os.environ', {}, clear=True): service = DailyConversationQuotaService(session) - assert await service.get_limit(USER_ID) is None + assert await service.get_limit(USER_ID, ORG_ID) is None @pytest.mark.asyncio @@ -80,9 +95,46 @@ async def test_user_null_org_exempt_takes_precedence_over_env(): """Org exemption (-1) takes precedence over env default.""" session = AsyncMock() session.scalar.side_effect = [ - SimpleNamespace(daily_conversation_limit=None, current_org_id=ORG_ID), + SimpleNamespace(daily_conversation_limit=None), # user SimpleNamespace(daily_conversation_limit=-1), # exempt ] with patch.dict('os.environ', {'OH_DAILY_CONVERSATION_LIMIT': '20'}): service = DailyConversationQuotaService(session) - assert await service.get_limit(USER_ID) is None + assert await service.get_limit(USER_ID, ORG_ID) is None + + +@pytest.mark.asyncio +async def test_missing_user_row_still_resolves_org_override(): + """A missing user row falls through to the org, not straight to env.""" + session = AsyncMock() + session.scalar.side_effect = [ + None, # user row absent + SimpleNamespace(daily_conversation_limit=7), # org + ] + with patch.dict('os.environ', {'OH_DAILY_CONVERSATION_LIMIT': '20'}): + service = DailyConversationQuotaService(session) + assert await service.get_limit(USER_ID, ORG_ID) == 7 + + +@pytest.mark.asyncio +async def test_limit_is_resolved_for_the_org_passed_in(): + """The org queried is the caller-supplied effective org. + + Regression guard: resolution must not fall back to the user's + ``current_org_id``, which is only their last-selected org and would + apply the wrong org's quota whenever the request is scoped elsewhere + via ``X-Org-Id`` or an org-bound API key. + """ + other_org = uuid4() + session = AsyncMock() + session.scalar.side_effect = [ + SimpleNamespace(daily_conversation_limit=None, current_org_id=other_org), + SimpleNamespace(daily_conversation_limit=42), + ] + service = DailyConversationQuotaService(session) + assert await service.get_limit(USER_ID, ORG_ID) == 42 + + org_query = session.scalar.await_args_list[1].args[0] + compiled = str(org_query.compile(compile_kwargs={'literal_binds': True})) + assert ORG_ID.hex in compiled + assert other_org.hex not in compiled From 5639c6806e11852df197665b46565b069c93e8eb Mon Sep 17 00:00:00 2001 From: hieptl Date: Tue, 25 Aug 2026 00:39:07 +0700 Subject: [PATCH 13/13] fix: log org quota changes with the calling admin Exempting an org from daily conversation limits is revenue-affecting, but the endpoint recorded nothing, so there was no way to answer who changed an org's quota or when. Bind the caller id that require_permission already returns and emit org_quota:set after the commit, matching the super_admins:grant / super_admins:revoke convention for instance-admin mutations. --- enterprise/server/routes/quota.py | 13 ++++++- .../unit/server/routes/test_quota_admin.py | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/enterprise/server/routes/quota.py b/enterprise/server/routes/quota.py index 1371297f4..8c966dc55 100644 --- a/enterprise/server/routes/quota.py +++ b/enterprise/server/routes/quota.py @@ -16,6 +16,7 @@ from openhands.app_server.user_auth import get_user_id from openhands.app_server.utils.dependencies import get_dependencies +from openhands.app_server.utils.logger import openhands_logger as logger quota_router = APIRouter( prefix='/api/quota', tags=['Quota'], dependencies=get_dependencies() @@ -97,7 +98,7 @@ class OrgQuotaResponse(BaseModel): async def set_org_quota( org_id: UUID, body: OrgQuotaUpdateRequest, - _: str = Depends(require_permission(Permission.MANAGE_ORG_QUOTA)), + caller_user_id: str = Depends(require_permission(Permission.MANAGE_ORG_QUOTA)), ) -> OrgQuotaResponse: """Set or clear an org-level daily conversation limit override. @@ -121,6 +122,16 @@ async def set_org_quota( org.daily_conversation_limit = body.daily_conversation_limit await session.commit() + logger.info( + 'org_quota:set', + extra={ + 'caller_user_id': caller_user_id, + 'org_id': str(org.id), + 'daily_conversation_limit': org.daily_conversation_limit, + 'exempt': org.daily_conversation_limit == EXEMPT_LIMIT, + }, + ) + return OrgQuotaResponse( org_id=str(org.id), org_name=org.name, diff --git a/enterprise/tests/unit/server/routes/test_quota_admin.py b/enterprise/tests/unit/server/routes/test_quota_admin.py index 3e3a9be8a..41785864c 100644 --- a/enterprise/tests/unit/server/routes/test_quota_admin.py +++ b/enterprise/tests/unit/server/routes/test_quota_admin.py @@ -234,3 +234,41 @@ async def test_conflicting_x_org_id_header_is_rejected( assert resp.status_code == 400 assert org.daily_conversation_limit is None + + +@pytest.mark.asyncio +async def test_successful_change_is_logged_with_the_caller( + mock_app, grant_manage_org_quota +): + """The mutation is attributable: who changed which org's quota, to what. + + Exempting an org is revenue-affecting, so an unattributable write is + not good enough -- this mirrors ``super_admins:grant`` / ``:revoke``. + """ + org = _fake_org() + with _patch_session(org), patch('server.routes.quota.logger') as log: + async with _client(mock_app) as client: + resp = await client.put(_url(), json={'daily_conversation_limit': -1}) + + assert resp.status_code == 200 + log.info.assert_called_once() + event, kwargs = log.info.call_args[0][0], log.info.call_args[1] + assert event == 'org_quota:set' + assert kwargs['extra'] == { + 'caller_user_id': CALLER_USER_ID, + 'org_id': str(ORG_ID), + 'daily_conversation_limit': -1, + 'exempt': True, + } + + +@pytest.mark.asyncio +async def test_denied_request_logs_no_change(mock_app, deny_manage_org_quota): + """A rejected call must not leave a record implying a change happened.""" + org = _fake_org() + with _patch_session(org), patch('server.routes.quota.logger') as log: + async with _client(mock_app) as client: + resp = await client.put(_url(), json={'daily_conversation_limit': 5}) + + assert resp.status_code == 403 + log.info.assert_not_called()