Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions app/routes/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
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

from app._time_util import iso_utc
from app.deps import get_db, get_key_context
from packages.auth.guards import require_unrestricted
from packages.auth.hashing import generate_api_key
from packages.auth.types import KeyContext
from packages.db.models.api_key import ApiKey
Expand All @@ -20,16 +21,25 @@

class CreateKey(BaseModel):
name: str
# Optional restrictions for child keys. Only reachable by unrestricted
# callers (require_unrestricted below), 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)

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 Enforce the budget_limit_cents cap the new key-minting API promises

The existing restriction mechanism for API keys — model_allowlist — is enforced on every inference path: chat.py:303 (pinned requests), auto_routing.py:306-313 (auto resolution filters the candidate set), and check_model_allowlist in anthropic_compat.py / gemini_compat.py (native surfaces + count_tokens). This change introduces the second restriction mechanism, budget_limit_cents, as a settable field on POST /v1/keys, and packages/auth/guards.py documents it as "a budget_limit_cents cap" and treats it as a real restriction (is_restricted returns True when set). But nothing anywhere in the request path reads budget_limit_cents to limit spend: not the AuthMiddleware, not validate_api_key, not chat.py/execute_chat, not router_cache. A "budgeted" child key minted via the new API (e.g. budget_limit_cents=500) is blocked from management endpoints but spends without any cap at inference time — exactly like an unrestricted key. So the new variant of "restricted key" omits the one thing the existing variant (allowlist) does: applying its limit at runtime. An operator who provisions a $5-capped key gets an unlimited key, and the only consumer-visible effect of the cap is the 403 on management routes. If the budget cap is intended to be functional (as the field name, docstring and test naming imply), the omission is a money/limit defect: the key is limited wrongly (not at all).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved by merging with #91: budget_limit_cents is enforced at inference time in #91 (packages/auth/spend.py + app/routes/chat.py — pre-check is_exhausted and charge_budget). #93 provisions the field and gates key-management; the runtime cap arrives with #91. Marked as a dependency in the PR description.



@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 @@ -39,6 +49,8 @@ async def list_keys(
"name": r.name,
"key_prefix": r.key_prefix,
"is_active": r.is_active,
"model_allowlist": r.model_allowlist,
"budget_limit_cents": r.budget_limit_cents,
"last_used_at": iso_utc(r.last_used_at),
"revoked_at": iso_utc(r.revoked_at),
"created_at": iso_utc(r.created_at),
Expand All @@ -54,12 +66,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 +85,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
3 changes: 3 additions & 0 deletions app/routes/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from app.deps import get_db, get_key_context
from app.router_cache import usable_providers_from_db
from packages.auth.encryption import encrypt_credential
from packages.auth.guards import require_unrestricted
from packages.auth.types import KeyContext
from packages.db.models.provider_key import ProviderKey

Expand Down Expand Up @@ -141,6 +142,7 @@ async def set_provider_key(
provider: str,
body: SetProviderKey,
_kc: KeyContext = Depends(get_key_context),
_restricted: None = Depends(require_unrestricted),
db: AsyncSession = Depends(get_db),
) -> dict:
if not body.api_key.strip():
Expand Down Expand Up @@ -198,6 +200,7 @@ async def set_provider_key(
async def delete_provider_key(
provider: str,
_kc: KeyContext = Depends(get_key_context),
_restricted: None = Depends(require_unrestricted),
db: AsyncSession = Depends(get_db),
) -> Response:
"""Hard-delete the DB row for this provider. After this, runtime
Expand Down
4 changes: 4 additions & 0 deletions app/routes/quality.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
load_overrides,
resolve_model_metrics,
)
from packages.auth.guards import require_unrestricted
from packages.auth.types import KeyContext
from packages.db.models.quality_score_override import QualityScoreOverride
from packages.litellm_adapter.catalog import CATALOG, CATALOG_BY_ID
Expand Down Expand Up @@ -157,6 +158,7 @@ async def quality_status(
@router.post("/refresh")
async def quality_refresh(
kc: KeyContext = Depends(get_key_context),
_restricted: None = Depends(require_unrestricted),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Force a fresh fetch of the AA Intelligence Index.
Expand Down Expand Up @@ -389,6 +391,7 @@ async def upsert_override(
model_id: str,
body: OverrideBody = Body(...),
kc: KeyContext = Depends(get_key_context),
_restricted: None = Depends(require_unrestricted),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Create or update an override. Idempotent on (workspace, model_id).
Expand Down Expand Up @@ -461,6 +464,7 @@ async def _do_merge() -> QualityScoreOverride:
async def delete_override(
model_id: str,
kc: KeyContext = Depends(get_key_context),
_restricted: None = Depends(require_unrestricted),
db: AsyncSession = Depends(get_db),
) -> None:
"""Remove an override. Returns 204 even if the override didn't exist —
Expand Down
2 changes: 2 additions & 0 deletions app/routes/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from app import cache_invalidation_bus
from app.deps import get_db, get_key_context
from app.seed import DEFAULT_WORKSPACE_ID
from packages.auth.guards import require_unrestricted
from packages.auth.types import KeyContext
from packages.db.models.routing_config import RoutingConfig

Expand Down Expand Up @@ -55,6 +56,7 @@ async def get_routing(
async def update_routing(
body: UpdateRouting,
_kc: KeyContext = Depends(get_key_context),
_restricted: None = Depends(require_unrestricted),
db: AsyncSession = Depends(get_db),
) -> dict:
row = (
Expand Down
40 changes: 40 additions & 0 deletions packages/auth/guards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Authorization guards for privileged key-management operations.

A *restricted* key is one that carries any limitation — a ``model_allowlist``
or a ``budget_limit_cents`` cap. Restricted keys are issued as child keys with
reduced privilege; letting them mint/rotate provider credentials, rewrite
routing, or override quality scores would let them escalate to the full
privilege of an unrestricted key. Only unrestricted keys may perform those
operations, so the escalation path is closed everywhere, not just on
``/v1/keys``.
"""

from __future__ import annotations

from fastapi import Depends, HTTPException

from app.deps import get_key_context
from packages.auth.types import KeyContext


def is_restricted(kc: KeyContext) -> bool:
"""True if the key carries any usage restriction."""
return kc.model_allowlist is not None or kc.budget_limit_cents is not None


def require_unrestricted(kc: KeyContext = Depends(get_key_context)) -> None:
"""FastAPI dependency: reject restricted keys from management endpoints.

Usable both as ``Depends(require_unrestricted)`` on a route and as a direct
``require_unrestricted(kc)`` call. Synchronous on purpose — it performs no
I/O, only a privilege check and a raise — so it works identically whether
FastAPI awaits it as a dependency or a route calls it inline.
"""
if is_restricted(kc):
raise HTTPException(
status_code=403,
detail=(
"Restricted API keys cannot perform management operations. "
"Use an unrestricted key."
),
)
Loading
Loading