gendesign/tradein-mvp/backend/app/services/account_quota.py
bot-backend 4feb61c006
Some checks failed
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
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) Failing after 5m27s
fix(tradein): резолвить роль из реестра, roles.yaml — только fallback (#3316)
Роль жила в двух местах сразу: люди заводятся в БД (`tradein_users.role`),
а `get_role` читал ТОЛЬКО `auth/roles.yaml` — и никто эти два источника не
сверял. Дефект двусторонний:

  * вверх: менеджер заводил сотрудника с именем, которое уже числится в
    roles.yaml админом (проверялись лишь regex и уникальность в БД) — на
    входе тот получал admin из YAML, то есть чтение ЛЮБОЙ чужой оценки
    (admin проходит мимо ownership-check в trade_in.py) и безлимитную квоту;
  * вниз: сотрудник, которого в roles.yaml нет, ловил KeyError → 403 на
    СОБСТВЕННУЮ оценку.

Источник теперь один и лечится один раз — в `app.core.auth.get_role`:
реестр (`tradein_users.role` / `auth.users.role`) спрашивается первым,
roles.yaml остаётся fallback для legacy-юзеров, у которых строки в реестре
нет. Реестр недоступен → тоже fallback: падение БД не выключает legacy-вход.
Вызывающие (rbac, trade_in, team, account_quota) не менялись.

Сопутствующее, чтобы поведение существующих аккаунтов не поехало:
  * rbac_guard выбирает матчер путей по РОДУ роли (роль реестра → DB_ROLE_PATHS),
    иначе employee/manager на legacy-пути получил бы 403 на всё;
  * get_user_scope отдаёт scope роли реестра из того же DB_ROLE_PATHS;
  * право на персональный `unlimited` осталось за roles.yaml (account_quota +
    _batch_quota_status) — фикс убирает эскалацию, а не раздаёт новую;
  * `_batch_quota_status` берёт роли из уже прочитанных строк — иначе список
    «Команды» снова стал бы N+1.

Defense-in-depth: create_employee отдаёт 409 на username, за которым в
roles.yaml числится не-employee роль.
2026-09-02 14:49:27 +05:00

281 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сервис квоты оценок 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, yaml_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
# #3316: get_role резолвит роль из реестра (БД) первой, поэтому сотрудник
# team-API больше не даёт KeyError. Право на ПЕРСОНАЛЬНЫЙ безлимит при этом
# осталось там же, где было — за roles.yaml: фикс убирает эскалацию, а не
# раздаёт новую. Иначе руками проставленный `unlimited` начал бы работать
# для аккаунтов, которым он раньше молча игнорировался (и разъехался бы с
# `_batch_quota_status` в списке «Команды»).
if yaml_role(username) is None:
return False
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