feature: API-ключи клиентов и журнал использования в Postgres (#4, #17)

- Postgres в compose; пакет db (SQLAlchemy async): clients, windows, requests.
- Все эндпоинты, кроме /health, — только с ключом в X-API-Key, иначе 401; без
  выпущенных ключей доступа нет. В базе — только sha256 ключа.
- Задача и окно сессии привязаны к клиенту: чужой ключ их не видит.
- Воркер пишет журнал: окно (открыто, последнее обращение, закрыто) и задачу
  (в очереди, начало, конец, итог, данные без секретов). Сбой записи не роняет задачу.
- python -m db.cli create|list|revoke|stats; make key-create/key-list/key-revoke/stats.
- smoke и loadtest с ключом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
protokeyandClaude Opus 5.5 committed 2026-10-13 21:48:48 +04:00
1 parent 9dc2c0b619
commit 1a4bd8d672
34 files changed
+857 -81

No files matched your search

+50 -15
View File
@@ -4,16 +4,21 @@
* POST /v2/tasks, GET /v2/tasks/{id}, DELETE /v2/sessions/current — асинхронный API (#80, #82).
* POST /v1/solve — прежний синхронный контракт поверх той же очереди (#84).
Все эндпоинты, кроме /health, — только с ключом клиента в X-API-Key (#4); ключи — в Postgres (db.cli).
Задача и окно сессии видны только клиенту, который их создал.
"""
import asyncio
import logging
import os
import time
import uuid
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Cookie, FastAPI, HTTPException, Response
from fastapi import Cookie, Depends, FastAPI, HTTPException, Response, Security
from fastapi.responses import JSONResponse
from fastapi.security import APIKeyHeader
from starlette.concurrency import run_in_threadpool
logging.basicConfig(
@@ -22,6 +27,9 @@ logging.basicConfig(
)
from api.schemas import SolveRequest, SolveResponse, TaskCreated, TaskRequest, TaskResponse # noqa: E402
from db import clients # noqa: E402
from db.engine import connect as connect_db # noqa: E402
from db.models import Client # noqa: E402
from tasks import broker as queue # noqa: E402
from tasks.sessions import new_session_id, parse_session_id # noqa: E402
from tasks.store import Status, TaskStore, connect # noqa: E402
@@ -34,8 +42,28 @@ V1_QUEUE_WAIT = float(os.getenv("V1_QUEUE_WAIT", "30"))
POLL_INTERVAL = 0.2
store = TaskStore(connect())
database = connect_db()
app = FastAPI()
@asynccontextmanager
async def lifespan(app: FastAPI):
await database.init()
yield
await database.close()
app = FastAPI(lifespan=lifespan)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
async def current_client(key: Annotated[str | None, Security(api_key_header)]) -> Client:
client = await clients.authenticate(database, key)
if client is None:
raise HTTPException(401, "invalid or missing X-API-Key")
return client
ClientDep = Annotated[Client, Depends(current_client)]
NO_SLOTS = {"status": "err", "code": "no_slots", "error": "No slots available"}
@@ -59,14 +87,15 @@ async def dispatch(message) -> None:
await run_in_threadpool(queue.broker.enqueue, message)
async def submit(payload: TaskRequest, session_id: str, window: dict | None) -> str:
async def submit(payload: TaskRequest, session_id: str, window: dict | None, client: Client, api: str) -> str:
"""Зарегистрировать задачу и поставить её в очередь: в персональную очередь воркера, где открыто
окно сессии (#83), иначе — в общую."""
task_id = str(uuid.uuid4())
await store.create(task_id, session_id, payload.url)
await store.create(task_id, session_id, payload.url, client.id)
target = queue.personal_queue(window["worker_id"]) if window else queue.COMMON_QUEUE
await dispatch(queue.task_message(
task_id, session_id, payload.model_dump(mode="json"), payload.browser_idle_timeout, target,
meta={"client_id": client.id, "api": api},
))
logger.info("task %s [%s] -> %s: %s", task_id, session_id, target, payload.url or "<current page>")
return task_id
@@ -76,6 +105,12 @@ async def free_slots() -> int:
return sum(w["free_slots"] for w in await store.workers())
async def client_window(session_id: str, client: Client) -> dict | None:
"""Открытое окно сессии, если оно этого клиента. Чужое окно — как отсутствующее."""
window = await store.live_window(session_id)
return window if window and window.get("client_id") == str(client.id) else None
@app.get("/")
@app.get("/health")
async def health():
@@ -92,32 +127,32 @@ async def health():
async def create_task(
payload: TaskRequest,
response: Response,
client: ClientDep,
session_id: Annotated[str | None, Cookie()] = None,
):
# TODO: добавить поддержку api ключей (#4)
session_id = use_session(response, session_id)
window = await store.live_window(session_id)
window = await client_window(session_id, client)
if payload.url is None and window is None:
raise HTTPException(422, "url is required: the session has no open browser window")
task_id = await submit(payload, session_id, window)
task_id = await submit(payload, session_id, window, client, "v2")
return {"task_id": task_id, "status": Status.QUEUED, "url": f"/v2/tasks/{task_id}"}
@app.get("/v2/tasks/{task_id}", response_model=TaskResponse, response_model_exclude_none=True)
async def get_task(task_id: str, session_id: Annotated[str | None, Cookie()] = None):
# задача видна только своей сессии; чужая и несуществующая неразличимы
async def get_task(task_id: str, client: ClientDep, session_id: Annotated[str | None, Cookie()] = None):
# задача видна только своему клиенту и своей сессии; чужая и несуществующая неразличимы
task = await store.get(task_id) if parse_session_id(task_id) else None
if not task or task["session_id"] != parse_session_id(session_id):
if not task or task["session_id"] != parse_session_id(session_id) or task.get("client_id") != str(client.id):
raise HTTPException(404, "task not found")
return {"task_id": task_id, "status": task["status"], "error": task.get("error"), "result": task.get("result")}
@app.delete("/v2/sessions/current")
async def close_session(session_id: Annotated[str | None, Cookie()] = None):
async def close_session(client: ClientDep, session_id: Annotated[str | None, Cookie()] = None):
"""Закрыть окно сессии досрочно и освободить слот."""
session_id = parse_session_id(session_id)
window = await store.get_session(session_id) if session_id else None
if not window:
if not window or window.get("client_id") != str(client.id):
return {"closed": False}
await store.delete_session(session_id)
if await store.worker_alive(window["worker_id"]):
@@ -129,19 +164,19 @@ async def close_session(session_id: Annotated[str | None, Cookie()] = None):
async def solve(
payload: SolveRequest,
response: Response,
client: ClientDep,
session_id: Annotated[str | None, Cookie()] = None,
):
"""Синхронный контракт v1: задача ставится в ту же очередь, ответ — когда воркер её выполнит.
Если свободных слотов нет или воркер не взял задачу за V1_QUEUE_WAIT — 503, как и раньше."""
# TODO: добавить поддержку api ключей (#4)
started = time.monotonic()
session_id = use_session(response, session_id)
window = await store.live_window(session_id)
window = await client_window(session_id, client)
if window is None and await free_slots() <= 0:
logger.warning("no free browser slots, rejecting %s", payload.url)
return no_slots_response()
task_id = await submit(TaskRequest.model_validate(payload.model_dump()), session_id, window)
task_id = await submit(TaskRequest.model_validate(payload.model_dump()), session_id, window, client, "v1")
while True:
task = await store.get(task_id)
if task is None: