Files
robotex/src/db/clients.py
T
protokeyandClaude Opus 5.5 1a4bd8d672 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>
2026-10-13 21:48:48 +04:00

49 lines
1.6 KiB
Python

"""Клиенты и API-ключи (#4). Ключ показывается один раз при выпуске, в базе — только sha256."""
import hashlib
import secrets
from sqlalchemy import select, update
from db.engine import Database
from db.models import Client, utcnow
KEY_PREFIX = "rbx_"
def hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
async def create_client(db: Database, name: str) -> tuple[Client, str]:
key = KEY_PREFIX + secrets.token_urlsafe(32)
client = Client(name=name, key_hash=hash_key(key), key_prefix=key[:12])
async with db.session() as session, session.begin():
session.add(client)
return client, key
async def authenticate(db: Database, key: str | None) -> Client | None:
"""Клиент по ключу, если ключ не отозван; отмечает время последнего обращения."""
if not key:
return None
async with db.session() as session, session.begin():
client = await session.scalar(
select(Client).where(Client.key_hash == hash_key(key), Client.revoked_at.is_(None))
)
if client:
client.last_access_at = utcnow()
return client
async def revoke(db: Database, client_id: int) -> bool:
async with db.session() as session, session.begin():
result = await session.execute(
update(Client).where(Client.id == client_id, Client.revoked_at.is_(None)).values(revoked_at=utcnow())
)
return result.rowcount > 0
async def list_clients(db: Database) -> list[Client]:
async with db.session() as session:
return list(await session.scalars(select(Client).order_by(Client.id)))