-
Notifications
You must be signed in to change notification settings - Fork 149
fix(observability): log every unhandled exception (500s) and auth-middleware failures #81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f6ca9b1
76f098f
9240492
6096169
3c7c104
f903402
1ed4e2c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,7 @@ | |
| from app.deps import get_db, get_key_context | ||
| from app.quality_scores import resolve_model_metrics | ||
| from app.schemas import ChatCompletionRequest | ||
| from packages.auth.spend import budget_exceeded, get_lifetime_spend_microcents | ||
| from packages.auth.types import KeyContext | ||
| from packages.db.models.request_log import RequestLog | ||
| from packages.litellm_adapter.catalog import CATALOG, CATALOG_BY_ID | ||
|
|
@@ -267,6 +268,21 @@ async def chat_completions( | |
| detail=f"Model '{body.model}' is not allowed for this API key", | ||
| ) | ||
|
|
||
| # Budget enforcement: `budget_limit_cents` is a lifetime cap on this | ||
| # key's billable (status < 400) spend. Checked before any routing, | ||
| # resolution, or cache work so an exhausted key costs the operator | ||
| # nothing — no upstream attempt, no cache fill. | ||
| if kc.budget_limit_cents is not None: | ||
| spend = await get_lifetime_spend_microcents(db, str(kc.key_id)) | ||
| if budget_exceeded(spend, kc.budget_limit_cents): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 P1 Make the budget check atomic with the spend recording The new lifetime-cap enforcement is a plain check-then-act with no atomicity. |
||
| raise HTTPException( | ||
| status_code=429, | ||
| detail=( | ||
| "API key budget exhausted " | ||
| f"({spend} of {kc.budget_limit_cents * 10_000} microcents spent)." | ||
| ), | ||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 P1 Make the budget check + spend recording atomic per key The new lifetime-cap enforcement is a check-then-act with no synchronization. chat_completions reads the accumulated spend (SELECT sum over requests_log, line 276) and, if below the cap, proceeds to the upstream call; the request's cost is only written into requests_log afterwards — on the blocking path in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 P2 Budget enforcement is silently bypassed whenever the request-log write fails The new lifetime-budget enforcement sums RequestLog.cost_microcents (status < 400) to decide whether a key is exhausted. Those spend rows are written only after the upstream call, and every write failure is swallowed: the blocking path does |
||
|
|
||
| client = await router_cache.get_router(db) | ||
| raw_strategy = getattr(client, "strategy", None) | ||
| strategy = raw_strategy if isinstance(raw_strategy, str) and raw_strategy else "balanced" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,7 @@ | |
| from datetime import datetime, timezone | ||
|
|
||
| from fastapi import APIRouter, Depends, HTTPException, Response | ||
| from pydantic import BaseModel | ||
| from pydantic import BaseModel, Field | ||
| from sqlalchemy import select | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
|
|
@@ -20,16 +20,46 @@ | |
|
|
||
| class CreateKey(BaseModel): | ||
| name: str | ||
| # Optional restrictions for child keys. Only reachable by unrestricted | ||
| # callers (require_unrestricted above), so a restricted key can never | ||
| # mint a sibling with looser limits than its own — it can't mint at all. | ||
| model_allowlist: list[str] | None = None | ||
| budget_limit_cents: int | None = Field(default=None, gt=0) | ||
|
|
||
|
|
||
| def require_unrestricted(kc: KeyContext) -> None: | ||
| """Key management is reserved for unrestricted keys. | ||
|
|
||
| A key that carries any restriction (`model_allowlist` or | ||
| `budget_limit_cents`) must not be able to mint, list, or revoke other | ||
| keys — otherwise it could create a sibling with no restrictions and | ||
| trivially bypass its own allowlist/budget. Unrestricted keys already | ||
| hold the maximum privilege this single-workspace edition exposes | ||
| (same trust level as PUT /v1/providers/*), so denying restricted keys | ||
| here grants nothing to anyone; it only closes the escalation path. | ||
| """ | ||
| if kc.model_allowlist is not None or kc.budget_limit_cents is not None: | ||
| raise HTTPException( | ||
| status_code=403, | ||
| detail=( | ||
| "Restricted API keys cannot manage keys. " | ||
| "Use an unrestricted key." | ||
| ), | ||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 P2 Apply require_unrestricted to /v1/providers, /v1/routing and quality overrides, not just /v1/keys require_unrestricted() is only applied to the three /v1/keys endpoints. Its own docstring defines the trust boundary as "Unrestricted keys already hold the maximum privilege this single-workspace edition exposes (same trust level as PUT /v1/providers/), so denying restricted keys here ... only closes the escalation path" — implying restricted keys do NOT hold the provider-write trust level. But app/routes/providers.py (PUT /{provider} at line 143, DELETE /{provider} at line 200), app/routes/routing.py (PUT at line 57) and app/routes/quality.py (PUT/DELETE /overrides) all accept any authenticated KeyContext with no require_unrestricted check. This change makes restricted keys (model_allowlist/budget_limit_cents) provisionable through POST /v1/keys for the first time, so a holder of such a key — the exact adversary the commit's invariant targets — can PUT /v1/providers/openai with a key it controls (redirecting the operator's chat traffic and prompts to an account the key holder controls) or DELETE /v1/providers/ (breaking routing for every key), without ever touching /v1/keys. The stated "escalation path is closed" invariant is therefore only half enforced; the same gate must be applied to the providers/routing/quality write endpoints. |
||
|
|
||
|
|
||
| @router.get("") | ||
| async def list_keys( | ||
| _kc: KeyContext = Depends(get_key_context), | ||
| kc: KeyContext = Depends(get_key_context), | ||
| db: AsyncSession = Depends(get_db), | ||
| ) -> dict: | ||
| require_unrestricted(kc) | ||
| rows = ( | ||
| await db.execute( | ||
| select(ApiKey).where(ApiKey.is_deleted == 0).order_by(ApiKey.created_at) | ||
| select(ApiKey).where( | ||
| ApiKey.workspace_id == kc.workspace_id, | ||
| ApiKey.is_deleted == 0, | ||
| ).order_by(ApiKey.created_at) | ||
| ) | ||
| ).scalars().all() | ||
| return { | ||
|
|
@@ -54,12 +84,15 @@ async def create_key( | |
| kc: KeyContext = Depends(get_key_context), | ||
| db: AsyncSession = Depends(get_db), | ||
| ) -> dict: | ||
| require_unrestricted(kc) | ||
| full_key, key_hash, key_prefix = generate_api_key() | ||
| row = ApiKey( | ||
| workspace_id=kc.workspace_id, | ||
| name=body.name, | ||
| key_hash=key_hash, | ||
| key_prefix=key_prefix, | ||
| model_allowlist=body.model_allowlist, | ||
| budget_limit_cents=body.budget_limit_cents, | ||
| ) | ||
| db.add(row) | ||
| await db.commit() | ||
|
|
@@ -70,18 +103,28 @@ async def create_key( | |
| "name": row.name, | ||
| "key_prefix": row.key_prefix, | ||
| "api_key": full_key, # plaintext shown ONCE | ||
| "model_allowlist": row.model_allowlist, | ||
| "budget_limit_cents": row.budget_limit_cents, | ||
| } | ||
|
|
||
|
|
||
| @router.delete("/{key_id}", status_code=204) | ||
| async def revoke_key( | ||
| key_id: str, | ||
| _kc: KeyContext = Depends(get_key_context), | ||
| kc: KeyContext = Depends(get_key_context), | ||
| db: AsyncSession = Depends(get_db), | ||
| ) -> Response: | ||
| require_unrestricted(kc) | ||
| row = ( | ||
| await db.execute( | ||
| select(ApiKey).where(ApiKey.id == key_id, ApiKey.is_deleted == 0) | ||
| select(ApiKey).where( | ||
| ApiKey.id == key_id, | ||
| # Workspace scoping: without this, any key could revoke any | ||
| # other workspace's keys (the write path has always been | ||
| # scoped; the read/delete paths were not). | ||
| ApiKey.workspace_id == kc.workspace_id, | ||
| ApiKey.is_deleted == 0, | ||
| ) | ||
| ) | ||
| ).scalar_one_or_none() | ||
| if row is None: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 P2 Make the documented ORCA_ALLOW_INSECURE_DEV_KEY opt-out actually reach the guard when set in .env
Every user-facing channel for the new opt-out flag names it
ORCA_ALLOW_INSECURE_DEV_KEY: the guard's own RuntimeError remediation ("set ORCA_ALLOW_INSECURE_DEV_KEY=1"), the encryption.py docstring and_warn_dev_fallback_oncewarning, and the tests. But the flag is consumed through two channels that never see that name in the repo's primary config path: (a)Settings.allow_insecure_dev_key— pydantic-settings derives the env/.env name from the field name (allow_insecure_dev_key→ALLOW_INSECURE_DEV_KEY; there is no env_prefix or alias in config.py), soORCA_ALLOW_INSECURE_DEV_KEYnever populates it; (b) the guard'sos.environ.get("ORCA_ALLOW_INSECURE_DEV_KEY")— which only sees real process env, and this very codebase documents (encryption.py: "pydantic-settings does NOT propagate .env values into os.environ") that.envvalues are not exported to os.environ. Consequence: an operator who follows the error message and addsORCA_ALLOW_INSECURE_DEV_KEY=1to.env(the repo's documented config channel,env_file=".env") and runs via scripts/start.py / uvicorn / railway getssettings.allow_insecure_dev_key=Falseandos.environwithout the flag, soassert_credential_encryption_readykeeps raising RuntimeError and the app refuses to boot even though the operator explicitly opted into the insecure dev key. Only the undocumentedALLOW_INSECURE_DEV_KEYname (or exporting ORCA_* as a real process env var, e.g. docker-compose env_file) works. Fix: give the Settings field an alias soORCA_ALLOW_INSECURE_DEV_KEYpopulatesallow_insecure_dev_key(e.g.Field(False, validation_alias="ORCA_ALLOW_INSECURE_DEV_KEY")), or rename the documented flag to match the pydantic-derived name.