Skip to content
9 changes: 8 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,16 @@ class Settings(BaseSettings):
port: int = 8000
log_level: str = "info"

# ── Encryption (auto-generated on first run if empty) ──
# ── Encryption ──
# NOTE: credential_encryption_key is NOT auto-generated. If empty, the
# app seals provider keys with a publicly-known dev key (with a loud
# warning), and packages.db.guards refuses to boot once real
# credentials are at stake. Generate one: `openssl rand -hex 32`.
credential_encryption_key: str = ""
api_key_pepper: str = ""
# Explicit opt-out from the startup guard that refuses to run with the
# publicly-known dev encryption key when real credentials are at stake.
allow_insecure_dev_key: bool = False

Copy link
Copy Markdown

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_once warning, 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_keyALLOW_INSECURE_DEV_KEY; there is no env_prefix or alias in config.py), so ORCA_ALLOW_INSECURE_DEV_KEY never populates it; (b) the guard's os.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 .env values are not exported to os.environ. Consequence: an operator who follows the error message and adds ORCA_ALLOW_INSECURE_DEV_KEY=1 to .env (the repo's documented config channel, env_file=".env") and runs via scripts/start.py / uvicorn / railway gets settings.allow_insecure_dev_key=False and os.environ without the flag, so assert_credential_encryption_ready keeps raising RuntimeError and the app refuses to boot even though the operator explicitly opted into the insecure dev key. Only the undocumented ALLOW_INSECURE_DEV_KEY name (or exporting ORCA_* as a real process env var, e.g. docker-compose env_file) works. Fix: give the Settings field an alias so ORCA_ALLOW_INSECURE_DEV_KEY populates allow_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.


# ── Provider keys via env (alternative to UI-stored keys) ──
# Keep in sync with `_PROVIDERS_FROM_ENV` above. Pydantic-settings reads
Expand Down
64 changes: 50 additions & 14 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:

from app.config import get_settings
from packages.db import session as session_mod
from packages.db.engine import dispose_engine, get_engine
from packages.db.engine import dispose_engine, get_engine, redacted_url
from packages.db.models.base import Base

settings = get_settings()
Expand All @@ -39,21 +39,37 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
),
)
log = structlog.get_logger()
log.info("lite_starting", database_url=settings.database_url)
# Redact: on the documented Postgres path the raw URL carries the DB
# password, and structured logs are retained by hosted aggregators.
log.info("lite_starting", database_url=redacted_url(settings.database_url))

engine = get_engine(settings.database_url)

async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

# Fail closed before any traffic can be served: refuse to boot when
# provider credentials are (or would be) sealed with the publicly-known
# dev encryption key. Runs after create_all so a fresh database's empty
# provider_keys table counts as "no credentials at risk".
from packages.db.guards import assert_credential_encryption_ready

await assert_credential_encryption_ready(
make_session=async_sessionmaker(engine, expire_on_commit=False),
database_url=settings.database_url,
allow_insecure_dev_key=settings.allow_insecure_dev_key,
)

session_mod._session_factory = async_sessionmaker(engine, expire_on_commit=False)

from app.seed import seed_initial_state

async with session_mod._session_factory() as s:
seed = await seed_initial_state(s)
if seed.created and seed.api_key:
log.info("seed_complete", api_key=seed.api_key)
# No key material in the structured event: logs are retained by
# aggregators. The print() below is the one-time delivery channel.
log.info("seed_complete", workspace_id=seed.workspace_id)
print(f"\n ✓ orcarouter-lite ready. API key: {seed.api_key}\n")

from app import cache_invalidation_bus
Expand All @@ -68,6 +84,36 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
await dispose_engine()


async def unhandled_exception_handler(request, exc: Exception):
"""Last-resort handler: log everything, return an opaque envelope.

Declared `async` on purpose: Starlette runs async exception handlers on
the event loop inside the `except` block where `sys.exc_info()` still
holds the active exception. A sync handler would be dispatched via
`run_in_threadpool` in a worker thread, where `exc_info` is empty and the
traceback would be lost — defeating the whole point of this handler.

The traceback MUST be recorded here — this is the only place an
arbitrary exception surfaces, and without it every production 500 is
undebuggable.
"""
structlog.get_logger().exception(
"unhandled_exception",
path=str(request.url.path),
error=str(exc),
exc_info=exc,
)
return JSONResponse(
status_code=500,
content={
"error": {
"message": "Internal server error",
"type": "server_error",
}
},
)


def create_app() -> FastAPI:
app = FastAPI(
title="OrcaRouter Lite",
Expand Down Expand Up @@ -106,17 +152,7 @@ async def val_exc_handler(_req, exc: RequestValidationError):
content={"error": {"message": msg, "type": "validation_error"}},
)

@app.exception_handler(Exception)
async def unhandled(_req, exc: Exception):
return JSONResponse(
status_code=500,
content={
"error": {
"message": "Internal server error",
"type": "server_error",
}
},
)
app.add_exception_handler(Exception, unhandled_exception_handler)

from app.middleware.auth import AuthMiddleware

Expand Down
7 changes: 7 additions & 0 deletions app/middleware/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@

import json

import structlog

from packages.auth.key_validator import AuthError, validate_api_key
from packages.db import session as session_mod

logger = structlog.get_logger()

SKIP_AUTH_PATHS: set[str] = {
"/health",
"/health/ready",
Expand Down Expand Up @@ -95,6 +99,9 @@ async def __call__(self, scope, receive, send):
await _send_error(send, e.status_code, e.message, "auth_error")
return
except Exception:
# A DB failure during key validation must be visible — without
# this log the operator only sees unexplained 503s.
logger.exception("auth_middleware_error", path=path)
await _send_error(send, 503, "Service temporarily unavailable", "server_error")
return

Expand Down
16 changes: 16 additions & 0 deletions app/routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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. get_lifetime_spend_microcents (packages/auth/spend.py:21-30) is an unlocked SELECT SUM over committed RequestLog rows; the current request's own cost is inserted only afterwards (blocking path chat.py:733-735, streaming _finalize chat.py:560-562, cache-hit chat.py:447-449). There is no per-key lock, no SELECT ... FOR UPDATE, no atomic reservation anywhere in the request path (the only asyncio.Lock in app/ is the router-cache build lock). Two or more concurrent requests for the same budgeted key both read the same pre-spend sum, both pass budget_exceeded(), both call the upstream, and both charge the key — the operator-set cap is exceeded by the combined cost of every in-flight request (unbounded under parallel load, which is the normal LLM usage pattern). The streaming path makes this worse: the cost is committed only when the stream ends, so the check window spans the entire stream duration, and every concurrent stream that started under the cap is admitted. The feature is documented as a hard lifetime cap ("an exhausted key costs the operator nothing — no upstream attempt"), so this is an accounted quantity (spend) limited wrongly. Fix: serialize per key — hold an in-process asyncio.Lock keyed by key_id from the moment the check passes until the RequestLog row is committed (including the streaming finalize), so each new request sees the previous one's committed spend; on Postgres additionally consider SELECT ... FOR UPDATE on the api_keys row, still spanning the upstream call since cost is only known afterwards.

raise HTTPException(
status_code=429,
detail=(
"API key budget exhausted "
f"({spend} of {kc.budget_limit_cents * 10_000} microcents spent)."
),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 finally commit (line 735) and on the streaming path in _finalize (line 556), each in its own transaction. Nothing serializes requests for the same key and no spend is reserved at check time, so N concurrent requests all read the same pre-existing spend, all pass the budget_exceeded check, and all incur upstream cost: the lifetime cap is exceeded by the sum of every concurrently in-flight request's cost. The window spans the entire upstream call (seconds to minutes for streams), so a burst or an attacker firing concurrent requests — exactly the leaked-key scenario this feature exists to bound — can overshoot the cap many times over, and the operator is billed beyond the configured limit. Fix: make admission atomic per key, e.g. keep a running spent_microcents on the ApiKey row and do a conditional atomic reservation before the upstream call (UPDATE api_keys SET budget_spent_microcents = budget_spent_microcents + :est WHERE id = :id AND budget_spent_microcents + :est <= :limit, checking rowcount) then settle the actual cost after completion; or serialize per-key admission with an asyncio lock around check + call.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 except Exception as commit_err: logger.warning("request_log_commit_failed", ...) (chat.py:734-737) and still returns 200, and the streaming path's _finalize swallows the same way. A key whose log commits fail (transient DB error, lock, full disk) therefore has its spend permanently understated: the requests are served and billed upstream, no row is ever recorded, and the budget check keeps seeing the old, lower total — the cap is silently never reached, so the "exhausted key costs the operator nothing" guarantee (comment at 271-274) is defeated with no error surfaced. Before this change the swallowed commit only degraded analytics; the new money-limiting control now depends on it.


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"
Expand Down
53 changes: 48 additions & 5 deletions app/routes/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."
),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 {
Expand All @@ -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()
Expand All @@ -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:
Expand Down
Loading