All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m12s
Блокер номер один перед открытием эндпоинта оценки наружу: анонимный запрос означал БЕЗЛИМИТ. В сервисе квот отсутствие имени пользователя трактовалось как unlimited во всех функциях, с комментарием «dev без Caddy, fail-open». Единственной защитой был общий лимит 300 запросов в минуту на IP — это анти-флуд для дешёвых запросов, а не бизнес-лимит для пайплайна на десятки секунд. 1. Fail-open больше не по умолчанию. Вызывающая сторона передаёт пустую личность только при явно включённом флаге разработки (по умолчанию выкл). 2. Анонимная личность — подписанная кука с HMAC-SHA256, отдельным каналом от X-Authenticated-User. Тот заголовок ставит Caddy и валидирует внутренним секретом; смешивать схемы нельзя, это сломало бы модель безопасности. Ключ подписи из окружения; если не задан — эфемерный на процесс, с предупреждением в лог. 3. Анонимная квота на паре «сессия + IP», переиспользует существующую таблицу и тот же атомарный инкремент под WHERE used < lim (защита от гонки #747). Честно закомментировано: смена IP или чистка куки обходит лимит — задача поднять стоимость злоупотребления, а не сделать его невозможным. 4. Отдельный жёсткий лимит частоты на оценку, проверяется ДО квоты. Переиспользован готовый SlidingWindowLimiter. Redis намеренно не задействован: прод работает одним воркером, состояние теряется только при рестарте, а основная защита — месячная квота в Postgres. Компромисс задокументирован. 5. Потолок времени ответа. Вызов Avito IMV шёл БЕЗ бюджета, в отличие от всех соседних — единственный источник неограниченного времени. Обёрнут. Суммарный худший случай: было ~186 с (36 с ограниченных плюс IMV без границы ~150 с), стало 56 с. Тесты: 2754 passed. Два существующих теста обновлены под изменившееся поведение fail-open — это ожидаемое изменение, не регрессия.
274 lines
13 KiB
Python
274 lines
13 KiB
Python
"""Сервис квоты оценок trade-in — N успешных оценок в месяц на ключ (аккаунт ИЛИ
|
||
анонимная сессия+IP, см. #b2c-antiabuse-2).
|
||
|
||
Правила:
|
||
- Лимит по умолчанию = settings.estimate_quota_limit успешных оценок за календарный
|
||
месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT,
|
||
default 15. Это дефолт для АУТЕНТИФИЦИРОВАННЫХ (X-Authenticated-User) ключей.
|
||
Анонимные ключи (см. app.api.v1.trade_in._resolve_quota_identity) используют
|
||
СВОЙ, гораздо более строгий default через параметр `default_limit=` —
|
||
все функции ниже принимают username-подобный `key: str | None` без разбора,
|
||
реальный аккаунт это или составной anon-ключ ("anon:<session>:<ip>").
|
||
- Персональный override: таблица account_quota_overrides (username → monthly_limit),
|
||
см. миграцию 185_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток
|
||
через negative `used` (ломал /quota — «Осталось 50 из 15»). Работает одинаково
|
||
для anon-ключей (в норме нет override-строки → falls back на переданный
|
||
`default_limit`), так и для обычных username.
|
||
- `used` в account_estimate_usage защищён CHECK (used >= 0) на уровне схемы, см.
|
||
миграцию 189_account_estimate_usage_nonnegative.sql — 185 сбросила негативный
|
||
used только для user2, 189 закрывает остальные аккаунты + запрещает регресс.
|
||
В коде декремента `used` НЕТ — increment() только `used + 1` под TOCTOU-guard
|
||
(#747); любой negative used приходит исключительно извне (ручной UPDATE).
|
||
- Без лимита (unlimited): роль admin (без похода в БД) ИЛИ персональный грант
|
||
account_quota_overrides.unlimited = true (миграция 191_account_quota_unlimited_flag.sql).
|
||
До миграции 191 unlimited для non-admin аккаунтов был захардкожен как
|
||
`username == 'kopylov'` прямо в коде — данные (kopylov + praktika) заменяют этот
|
||
хардкод целиком, единый источник правды для всех безлимитных non-admin грантов.
|
||
Анонимные ключи никогда не unlimited (get_role() кидает KeyError на составной
|
||
anon-ключ → is_unlimited() шорткатится в False БЕЗ похода в БД).
|
||
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
|
||
- key is None → unlimited, лимит не применяется (fail-open). #b2c-antiabuse-2:
|
||
ЭТОТ модуль как был, так и остаётся fail-open на None — но с этапа anti-abuse
|
||
вызывающая сторона (app.api.v1.trade_in._resolve_quota_identity) передаёт None
|
||
ТОЛЬКО за явным флагом settings.quota_dev_fail_open (по умолчанию ВЫКЛЮЧЕН).
|
||
Анонимный запрос без этого флага получает anon-ключ (см. app.core.anon_session),
|
||
а не None — то есть на практике анонимные пользователи в проде квоту получают,
|
||
а не безлимит.
|
||
- При исчерпании лимита поднимается HTTPException(429).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import UTC, datetime
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.auth import get_role
|
||
from app.core.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Лимит успешных оценок за календарный месяц — конфигурируется через
|
||
# env ESTIMATE_QUOTA_LIMIT (core.config.Settings), default 15 (#658).
|
||
MONTHLY_LIMIT = settings.estimate_quota_limit
|
||
|
||
|
||
def limit_exhausted_message(limit: int) -> str:
|
||
"""Текст 429 при исчерпании лимита — параметризован реальным лимитом (может
|
||
отличаться от глобального MONTHLY_LIMIT для персонального override ИЛИ
|
||
anon default_limit, см. #b2c-antiabuse-2)."""
|
||
return (
|
||
f"Лимит из {limit} оценок в этом месяце исчерпан. "
|
||
"За полной версией обращайтесь к Копылову."
|
||
)
|
||
|
||
|
||
# Backward-compat константа для MONTHLY_LIMIT-based сценариев (тесты, existing
|
||
# imports) — байт-в-байт совпадает с limit_exhausted_message(MONTHLY_LIMIT).
|
||
LIMIT_EXHAUSTED_MESSAGE = limit_exhausted_message(MONTHLY_LIMIT)
|
||
|
||
|
||
def current_period() -> str:
|
||
"""Возвращает текущий период в формате 'YYYY-MM' (UTC)."""
|
||
return datetime.now(UTC).strftime("%Y-%m")
|
||
|
||
|
||
def is_unlimited(db: Session, username: str) -> bool:
|
||
"""True если пользователь не ограничен квотой.
|
||
|
||
Unlimited если:
|
||
- роль admin (RBAC roles.yaml, in-memory, БЕЗ похода в БД — admin гарантированно
|
||
безлимитен по дизайну RBAC, отдельная per-user запись не нужна);
|
||
- ЛИБО персональный грант account_quota_overrides.unlimited = true (миграция
|
||
191) — единственный источник правды для non-admin безлимитных аккаунтов,
|
||
включая kopylov (перенесён сюда этой же миграцией, до 191 был захардкожен
|
||
как `username == 'kopylov'`) и praktika (пилот восстановлен 2026-07-27).
|
||
|
||
KeyError (неизвестный пользователь, не в roles.yaml) → трактуется как limited
|
||
(False), БЕЗ похода в БД — override-таблица не источник правды для юзеров,
|
||
которых вообще нет в RBAC-конфиге.
|
||
"""
|
||
try:
|
||
role = get_role(username)
|
||
except KeyError:
|
||
return False
|
||
if role == "admin":
|
||
return True
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT unlimited FROM account_quota_overrides
|
||
WHERE username = :u
|
||
"""
|
||
),
|
||
{"u": username},
|
||
).fetchone()
|
||
return bool(row is not None and row.unlimited)
|
||
|
||
|
||
def user_limit(db: Session, username: str, *, default: int = MONTHLY_LIMIT) -> int:
|
||
"""Персональный месячный лимит для username, иначе *default*.
|
||
|
||
Источник override — таблица account_quota_overrides (см. миграцию
|
||
185_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через
|
||
negative `used`, который ломал /quota (limit=15, used=-35 → remaining=50 —
|
||
«Осталось 50 из 15»).
|
||
|
||
*default* параметризован (не всегда MONTHLY_LIMIT) ради anon-ключей
|
||
(#b2c-antiabuse-2): анонимный ("anon:<session>:<ip>") ключ в норме не имеет
|
||
override-строки → падает на *default*, который вызывающая сторона задаёт
|
||
равным settings.anon_estimate_quota_limit (гораздо строже пилот-лимита).
|
||
"""
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT monthly_limit FROM account_quota_overrides
|
||
WHERE username = :u
|
||
"""
|
||
),
|
||
{"u": username},
|
||
).fetchone()
|
||
if row is not None and row.monthly_limit is not None:
|
||
return int(row.monthly_limit)
|
||
return default
|
||
|
||
|
||
def get_status(db: Session, username: str | None, *, default_limit: int = MONTHLY_LIMIT) -> dict:
|
||
"""Возвращает статус квоты для пользователя (или anon-ключа, #b2c-antiabuse-2).
|
||
|
||
Если username is None → unlimited True, used 0, remaining = default_limit
|
||
(fail-open — вызывающая сторона передаёт None ТОЛЬКО за явным dev-флагом,
|
||
см. app.api.v1.trade_in._resolve_quota_identity).
|
||
Если unlimited → used = фактический или 0, remaining = limit (per-user override
|
||
или *default_limit*).
|
||
"""
|
||
if username is None:
|
||
return {
|
||
"limit": default_limit,
|
||
"used": 0,
|
||
"remaining": default_limit,
|
||
"unlimited": True,
|
||
}
|
||
|
||
unlimited = is_unlimited(db, username)
|
||
period = current_period()
|
||
limit = user_limit(db, username, default=default_limit)
|
||
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT used FROM account_estimate_usage
|
||
WHERE username = :u AND period_month = :p
|
||
"""
|
||
),
|
||
{"u": username, "p": period},
|
||
).fetchone()
|
||
|
||
used = row.used if row is not None else 0
|
||
|
||
if unlimited:
|
||
return {
|
||
"limit": limit,
|
||
"used": used,
|
||
"remaining": limit,
|
||
"unlimited": True,
|
||
}
|
||
|
||
# Защитный кламп: remaining никогда не превышает limit, даже если used всё же
|
||
# снова просочится отрицательным (прежний бонус-хак) — max(0, used) обнуляет
|
||
# отрицательный used перед вычитанием.
|
||
remaining = max(0, limit - max(0, used))
|
||
return {
|
||
"limit": limit,
|
||
"used": used,
|
||
"remaining": remaining,
|
||
"unlimited": False,
|
||
}
|
||
|
||
|
||
def check_and_raise(
|
||
db: Session, username: str | None, *, default_limit: int = MONTHLY_LIMIT
|
||
) -> None:
|
||
"""Проверяет лимит квоты и поднимает 429 если исчерпан.
|
||
|
||
Если username is None или пользователь unlimited → no-op. *default_limit*
|
||
задаёт лимит для ключей без персонального override (пилот → MONTHLY_LIMIT,
|
||
anon-ключ → settings.anon_estimate_quota_limit, см. #b2c-antiabuse-2).
|
||
"""
|
||
if username is None:
|
||
return
|
||
|
||
if is_unlimited(db, username):
|
||
return
|
||
|
||
period = current_period()
|
||
limit = user_limit(db, username, default=default_limit)
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT used FROM account_estimate_usage
|
||
WHERE username = :u AND period_month = :p
|
||
"""
|
||
),
|
||
{"u": username, "p": period},
|
||
).fetchone()
|
||
|
||
used = row.used if row is not None else 0
|
||
if used >= limit:
|
||
logger.warning(
|
||
"quota exhausted: username=%r period=%s used=%d limit=%d",
|
||
username,
|
||
period,
|
||
used,
|
||
limit,
|
||
)
|
||
raise HTTPException(status_code=429, detail=limit_exhausted_message(limit))
|
||
|
||
|
||
def increment(db: Session, username: str | None, *, default_limit: int = MONTHLY_LIMIT) -> bool:
|
||
"""Атомарно-условный инкремент счётчика успешных оценок (#747).
|
||
|
||
Возвращает True если инкремент успешен; False если лимит исчерпан.
|
||
None / unlimited → True (no-op success). *default_limit* — см. check_and_raise.
|
||
|
||
Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE —
|
||
два параллельных запроса при used=lim-1 не могут оба инкрементировать (второй
|
||
упрётся в WHERE → RETURNING пуст → False). Свежая вставка (used=1) НЕ задевается
|
||
WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит. `lim` —
|
||
персональный лимит (user_limit), НЕ жёстко зашитый глобальный MONTHLY_LIMIT.
|
||
"""
|
||
if username is None or is_unlimited(db, username):
|
||
return True
|
||
|
||
period = current_period()
|
||
lim = user_limit(db, username, default=default_limit)
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
INSERT INTO account_estimate_usage (username, period_month, used, updated_at)
|
||
VALUES (:u, :p, 1, NOW())
|
||
ON CONFLICT (username, period_month)
|
||
DO UPDATE SET
|
||
used = account_estimate_usage.used + 1,
|
||
updated_at = NOW()
|
||
WHERE account_estimate_usage.used < :lim
|
||
RETURNING used
|
||
"""
|
||
),
|
||
{"u": username, "p": period, "lim": lim},
|
||
).fetchone()
|
||
db.commit()
|
||
ok = row is not None
|
||
if ok:
|
||
logger.debug("quota incremented: username=%r period=%s used=%s", username, period, row[0])
|
||
else:
|
||
logger.warning(
|
||
"quota increment refused (atomic, #747): username=%r period=%s limit=%d",
|
||
username,
|
||
period,
|
||
lim,
|
||
)
|
||
return ok
|