- 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>
49 lines
1.6 KiB
Python
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)))
|