diff --git a/app/routes/keys.py b/app/routes/keys.py index 2d1ca86..380b845 100644 --- a/app/routes/keys.py +++ b/app/routes/keys.py @@ -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." + ), + ) @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: diff --git a/tests/integration/test_keys_authz.py b/tests/integration/test_keys_authz.py new file mode 100644 index 0000000..b0a7965 --- /dev/null +++ b/tests/integration/test_keys_authz.py @@ -0,0 +1,221 @@ +"""Key-management authorization tests. + +A restricted key (model_allowlist or budget_limit_cents set) must never be +able to mint, list, or revoke API keys — otherwise it could mint an +unrestricted sibling and bypass its own restrictions entirely. +See issue: restricted-key privilege escalation via POST /v1/keys. +""" + +import pytest + + +@pytest.fixture +async def seeded_keys(db_session): + """Seed the workspace root key plus one restricted and one budgeted key. + + Returns (root_full_key, restricted_full_key, budgeted_full_key). + """ + from app.seed import seed_initial_state + from packages.auth.hashing import generate_api_key + from packages.db.models.api_key import ApiKey + + seed = await seed_initial_state(db_session) + assert seed.api_key is not None + + def _make(**kwargs) -> str: + full_key, key_hash, key_prefix = generate_api_key() + row = ApiKey( + workspace_id="default", + name=kwargs.pop("name", "test"), + key_hash=key_hash, + key_prefix=key_prefix, + **kwargs, + ) + db_session.add(row) + return full_key + + # flush once so all rows land before any request reads them + restricted = _make(name="restricted", model_allowlist=["gpt-4o-mini"]) + budgeted = _make(name="budgeted", budget_limit_cents=500) + await db_session.commit() + return seed.api_key, restricted, budgeted + + +@pytest.fixture +async def keys_app(db_session, monkeypatch): + """FastAPI app with auth middleware and only the /v1/keys routes mounted.""" + monkeypatch.setenv("DATABASE_URL", str(db_session.bind.url)) + from fastapi import FastAPI + + from app.middleware.auth import AuthMiddleware + from packages.db import session as session_mod + + class _PassthroughFactory: + async def __aenter__(self): + return db_session + + async def __aexit__(self, *exc): + return False # propagate, don't close — fixture owns the session + + monkeypatch.setattr(session_mod, "_session_factory", lambda: _PassthroughFactory()) + + from app.routes.keys import router as keys_router + + app = FastAPI() + app.add_middleware(AuthMiddleware) + app.include_router(keys_router) + return app + + +async def _client(app): + from httpx import ASGITransport, AsyncClient + + return AsyncClient(transport=ASGITransport(app=app), base_url="http://t") + + +@pytest.mark.parametrize("which", [1, 2], ids=["allowlist-restricted", "budget-restricted"]) +async def test_restricted_key_cannot_create_keys(keys_app, seeded_keys, db_session, which): + keys, restricted, budgeted = seeded_keys + caller = (restricted, budgeted)[which - 1] + async with await _client(keys_app) as c: + r = await c.post( + "/v1/keys", + json={"name": "escalated"}, + headers={"Authorization": f"Bearer {caller}"}, + ) + assert r.status_code == 403 + # The escalation must not have persisted anything. + from sqlalchemy import func, select + + from packages.db.models.api_key import ApiKey + + count = ( + await db_session.execute(select(func.count()).select_from(ApiKey)) + ).scalar_one() + assert count == 3 # root + restricted + budgeted, nothing new + + +@pytest.mark.parametrize("which", [1, 2], ids=["allowlist-restricted", "budget-restricted"]) +async def test_restricted_key_cannot_list_keys(keys_app, seeded_keys, which): + keys, restricted, budgeted = seeded_keys + caller = (restricted, budgeted)[which - 1] + async with await _client(keys_app) as c: + r = await c.get("/v1/keys", headers={"Authorization": f"Bearer {caller}"}) + assert r.status_code == 403 + + +async def test_restricted_key_cannot_revoke_keys(keys_app, seeded_keys, db_session): + _, restricted, _budgeted = seeded_keys + from sqlalchemy import select + + from packages.db.models.api_key import ApiKey + + rows = (await db_session.execute(select(ApiKey))).scalars().all() + target_id = next(r.id for r in rows if r.name == "default") + async with await _client(keys_app) as c: + r = await c.delete( + f"/v1/keys/{target_id}", + headers={"Authorization": f"Bearer {restricted}"}, + ) + assert r.status_code == 403 + target = next(r for r in rows if r.name == "default") + assert target.is_active # untouched + + +async def test_unrestricted_key_retains_full_management(keys_app, seeded_keys): + root, _restricted, _budgeted = seeded_keys + h = {"Authorization": f"Bearer {root}"} + async with await _client(keys_app) as c: + listed = await c.get("/v1/keys", headers=h) + assert listed.status_code == 200 + + created = await c.post("/v1/keys", json={"name": "child"}, headers=h) + assert created.status_code == 201 + child_id = created.json()["id"] + + revoked = await c.delete(f"/v1/keys/{child_id}", headers=h) + assert revoked.status_code == 204 + + +async def test_create_key_accepts_restrictions(keys_app, seeded_keys, db_session): + root, *_ = seeded_keys + h = {"Authorization": f"Bearer {root}"} + async with await _client(keys_app) as c: + r = await c.post( + "/v1/keys", + json={"name": "team-a", "model_allowlist": ["gpt-4o-mini"], "budget_limit_cents": 500}, + headers=h, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["model_allowlist"] == ["gpt-4o-mini"] + assert body["budget_limit_cents"] == 500 + + from sqlalchemy import select + + from packages.db.models.api_key import ApiKey + + row = ( + await db_session.execute(select(ApiKey).where(ApiKey.id == body["id"])) + ).scalar_one() + assert row.budget_limit_cents == 500 + assert row.model_allowlist == ["gpt-4o-mini"] + + +# ── Workspace scoping (IDOR regression tests) ──────────────────────────── + + +async def _make_foreign_workspace_key(db_session) -> tuple[str, str]: + """A key belonging to a different workspace; returns (id, name).""" + from packages.auth.hashing import generate_api_key + from packages.db.models.api_key import ApiKey + from packages.db.models.workspace import Workspace + + db_session.add(Workspace(id="ws-other", name="Other", slug="other")) + await db_session.flush() + + full_key, key_hash, key_prefix = generate_api_key() + row = ApiKey( + workspace_id="ws-other", + name="foreign-key", + key_hash=key_hash, + key_prefix=key_prefix, + ) + db_session.add(row) + await db_session.commit() + return row.id, full_key + + +async def test_list_keys_hides_other_workspaces(keys_app, seeded_keys, db_session): + root, *_ = seeded_keys + foreign_id, _foreign_key = await _make_foreign_workspace_key(db_session) + + async with await _client(keys_app) as c: + r = await c.get( + "/v1/keys", headers={"Authorization": f"Bearer {root}"} + ) + + assert r.status_code == 200 + listed_ids = {k["id"] for k in r.json()["keys"]} + assert foreign_id not in listed_ids + + +async def test_revoke_rejects_other_workspaces_key(keys_app, seeded_keys, db_session): + from sqlalchemy import select + + from packages.db.models.api_key import ApiKey + + root, *_ = seeded_keys + foreign_id, _foreign_key = await _make_foreign_workspace_key(db_session) + + async with await _client(keys_app) as c: + r = await c.delete( + f"/v1/keys/{foreign_id}", + headers={"Authorization": f"Bearer {root}"}, + ) + + assert r.status_code == 404 + row = ( + await db_session.execute(select(ApiKey).where(ApiKey.id == foreign_id)) + ).scalar_one() + assert row.is_active # untouched