fix(tradein/team): revoke сессий при смене пароля + Origin-check + батч-квоты (#2554)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
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 1m56s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
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 1m56s
Deep-review PR #2563: - HIGH: PATCH new_password не ревокал старые сессии сотрудника — sliding-refresh продлевал скомпрометированную/чужую сессию бесконечно. Теперь revoke_user_sessions вызывается при is_active=False ИЛИ new_password (self-lockout невозможен — _fetch_employee_row фильтрует role='employee'). - Medium1: Origin/Referer-check (защита CSRF) на POST/PATCH team-роутах против settings.cors_origins; оба заголовка отсутствуют (curl-смоук внутри контейнера) — пропускаем, не ломаем прод-смоук. - Medium2: батч-квота для GET /employees (_batch_quota_status) — было 2N+3 SQL-запросов на N сотрудников, стало 5 плоско (замерено: N=10 -> 5 запросов). Добавлена пагинация limit(default 50, max 200)/offset. - Заодно: статические WHERE-ветки вместо f-string-сборки в list_employees; monthly_limit теперь сбрасывает account_quota_overrides.unlimited=false и не затирает существующий note.
This commit is contained in:
parent
712c56f456
commit
4ac3971061
2 changed files with 346 additions and 39 deletions
|
|
@ -25,6 +25,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -32,6 +33,7 @@ from sqlalchemy.engine import RowMapping
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.auth import get_role
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
from app.core.password import hash_password
|
from app.core.password import hash_password
|
||||||
|
|
@ -92,6 +94,47 @@ async def current_team_actor(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _origin_host_allowed(candidate: str) -> bool:
|
||||||
|
"""True если scheme://netloc *candidate* совпадает с одним из `settings.cors_origins`.
|
||||||
|
|
||||||
|
`cors_origins` уже является источником правды для «какие origin'ы это наш
|
||||||
|
фронт» (см. CORSMiddleware в app/main.py, ENV CORS_ORIGINS) — переиспользуем
|
||||||
|
его вместо нового хардкода."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(candidate)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if not parsed.scheme or not parsed.netloc:
|
||||||
|
return False
|
||||||
|
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
return origin in settings.cors_origins
|
||||||
|
|
||||||
|
|
||||||
|
def _require_same_origin(request: Request) -> None:
|
||||||
|
"""CSRF defense-in-depth (issue #2554 DoD) для state-changing team-роутов
|
||||||
|
(POST/PATCH): `Origin` (или `Referer` как fallback) обязан матчить один из
|
||||||
|
`settings.cors_origins`, иначе 403.
|
||||||
|
|
||||||
|
Оба заголовка отсутствуют → ПРОПУСКАЕМ (не 403). Причина: это единственный
|
||||||
|
надёжный сигнал non-browser клиента в этом стеке — curl-смоуки внутри
|
||||||
|
контейнера (см. `.claude/rules/tradein.md` "Тестировать HTTP только ВНУТРИ
|
||||||
|
контейнера", `docker exec tradein-backend curl ...`) не шлют ни один из этих
|
||||||
|
заголовков, а реальный браузер (fetch/XHR/form) ВСЕГДА прикладывает Origin
|
||||||
|
на unsafe-методах (POST/PATCH) — так что "оба отсутствуют" практически
|
||||||
|
невозможно для настоящего кросс-сайтового CSRF через браузер. Session-cookie
|
||||||
|
уже стоит на `SameSite=Lax` (см. `app.api.v1.auth.login`) — это первый рубеж
|
||||||
|
против CSRF, Origin-check — второй.
|
||||||
|
"""
|
||||||
|
candidate = request.headers.get("origin") or request.headers.get("referer")
|
||||||
|
if candidate is None:
|
||||||
|
return
|
||||||
|
if not _origin_host_allowed(candidate):
|
||||||
|
logger.warning(
|
||||||
|
"team: Origin/Referer mismatch %r on %s — possible CSRF", candidate, request.url.path
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=403, detail="origin not allowed")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -131,14 +174,22 @@ def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping:
|
||||||
def _upsert_quota_override(
|
def _upsert_quota_override(
|
||||||
db: Session, username: str, monthly_limit: int, actor_username: str
|
db: Session, username: str, monthly_limit: int, actor_username: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Upsert персонального лимита. Явная установка monthly_limit — сигнал "хочу
|
||||||
|
numeric-квоту", поэтому ВСЕГДА сбрасывает `unlimited=false` (иначе лимит может
|
||||||
|
молча не применяться — прежний unlimited-грант выигрывал бы у нового limit).
|
||||||
|
`note` — НЕ затирается, если уже задан (`COALESCE`): не перезаписываем
|
||||||
|
человеко-читаемую причину прошлого гранта (напр. "пилот, грант ...") молча
|
||||||
|
сгенерированной строкой; note проставляется только при первом upsert записи.
|
||||||
|
"""
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO account_quota_overrides (username, monthly_limit, note)
|
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
|
||||||
VALUES (:username, CAST(:monthly_limit AS integer), :note)
|
VALUES (:username, CAST(:monthly_limit AS integer), false, :note)
|
||||||
ON CONFLICT (username) DO UPDATE SET
|
ON CONFLICT (username) DO UPDATE SET
|
||||||
monthly_limit = EXCLUDED.monthly_limit,
|
monthly_limit = EXCLUDED.monthly_limit,
|
||||||
note = EXCLUDED.note,
|
unlimited = false,
|
||||||
|
note = COALESCE(account_quota_overrides.note, EXCLUDED.note),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -150,6 +201,83 @@ def _upsert_quota_override(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
|
||||||
|
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
|
||||||
|
|
||||||
|
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`:
|
||||||
|
unlimited = admin-роль (roles.yaml, in-memory, без похода в БД) ИЛИ
|
||||||
|
`account_quota_overrides.unlimited=true`; limit = override.monthly_limit,
|
||||||
|
иначе глобальный `account_quota.MONTHLY_LIMIT`.
|
||||||
|
"""
|
||||||
|
if not usernames:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
overrides = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT username, monthly_limit, unlimited
|
||||||
|
FROM account_quota_overrides
|
||||||
|
WHERE username = ANY(CAST(:usernames AS text[]))
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"usernames": usernames},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
override_by_username = {r["username"]: r for r in overrides}
|
||||||
|
|
||||||
|
period = account_quota.current_period()
|
||||||
|
usage_rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT username, used
|
||||||
|
FROM account_estimate_usage
|
||||||
|
WHERE username = ANY(CAST(:usernames AS text[])) AND period_month = :period
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"usernames": usernames, "period": period},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
used_by_username = {r["username"]: r["used"] for r in usage_rows}
|
||||||
|
|
||||||
|
result: dict[str, dict[str, Any]] = {}
|
||||||
|
for username in usernames:
|
||||||
|
override = override_by_username.get(username)
|
||||||
|
try:
|
||||||
|
is_admin_role = get_role(username) == "admin"
|
||||||
|
except KeyError:
|
||||||
|
is_admin_role = False
|
||||||
|
unlimited = is_admin_role or bool(override is not None and override["unlimited"])
|
||||||
|
limit = (
|
||||||
|
int(override["monthly_limit"])
|
||||||
|
if override is not None and override["monthly_limit"] is not None
|
||||||
|
else account_quota.MONTHLY_LIMIT
|
||||||
|
)
|
||||||
|
used = used_by_username.get(username, 0)
|
||||||
|
if unlimited:
|
||||||
|
result[username] = {
|
||||||
|
"limit": limit,
|
||||||
|
"used": used,
|
||||||
|
"remaining": limit,
|
||||||
|
"unlimited": True,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
remaining = max(0, limit - max(0, used))
|
||||||
|
result[username] = {
|
||||||
|
"limit": limit,
|
||||||
|
"used": used,
|
||||||
|
"remaining": remaining,
|
||||||
|
"unlimited": False,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
|
def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
|
||||||
return EmployeeOut(
|
return EmployeeOut(
|
||||||
id=row["id"],
|
id=row["id"],
|
||||||
|
|
@ -174,6 +302,7 @@ async def create_employee(
|
||||||
body: EmployeeCreateRequest,
|
body: EmployeeCreateRequest,
|
||||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
||||||
) -> EmployeeOut:
|
) -> EmployeeOut:
|
||||||
"""Создать сотрудника. Роль всегда `employee`.
|
"""Создать сотрудника. Роль всегда `employee`.
|
||||||
|
|
||||||
|
|
@ -277,12 +406,18 @@ async def update_employee(
|
||||||
body: EmployeeUpdateRequest,
|
body: EmployeeUpdateRequest,
|
||||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
||||||
) -> EmployeeOut:
|
) -> EmployeeOut:
|
||||||
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
|
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
|
||||||
|
|
||||||
manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404.
|
manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404.
|
||||||
При is_active=False — обязательно revoke всех сессий (иначе блокировка не
|
При is_active=False ИЛИ смене пароля (new_password) — обязательно revoke всех
|
||||||
подействует до истечения TTL текущей сессии сотрудника).
|
сессий (HIGH, deep-review PR #2563): без этого блокировка/reset не подействуют
|
||||||
|
до истечения TTL текущей сессии сотрудника — хуже того, sliding-refresh
|
||||||
|
(`app.services.auth_session.get_session_user`) продлевает `expires_at` на
|
||||||
|
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
|
||||||
|
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
|
||||||
|
use-case — см. его докстринг.
|
||||||
"""
|
"""
|
||||||
row = _fetch_employee_row(db, employee_id)
|
row = _fetch_employee_row(db, employee_id)
|
||||||
row = _authorize_employee(actor, row)
|
row = _authorize_employee(actor, row)
|
||||||
|
|
@ -320,10 +455,12 @@ async def update_employee(
|
||||||
if body.monthly_limit is not None:
|
if body.monthly_limit is not None:
|
||||||
_upsert_quota_override(db, row["username"], body.monthly_limit, actor.username)
|
_upsert_quota_override(db, row["username"], body.monthly_limit, actor.username)
|
||||||
|
|
||||||
if body.is_active is False:
|
if body.is_active is False or body.new_password is not None:
|
||||||
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
|
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
|
||||||
# коммитит сам (см. app.services.auth_session), это флашит и наш
|
# коммитит сам (см. app.services.auth_session), это флашит и наш
|
||||||
# предшествующий UPDATE/quota-upsert в той же сессии.
|
# предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout
|
||||||
|
# невозможен: _fetch_employee_row фильтрует role='employee', actor
|
||||||
|
# (admin|manager) никогда не может патчить сам себя через этот роут.
|
||||||
revoke_user_sessions(db, employee_id)
|
revoke_user_sessions(db, employee_id)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -381,44 +518,68 @@ async def update_employee(
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
|
||||||
|
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
|
||||||
|
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
|
||||||
|
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
||||||
|
FROM tradein_users
|
||||||
|
WHERE role = 'employee' AND manager_id = :manager_id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_LIST_EMPLOYEES_ALL_SQL = text(
|
||||||
|
"""
|
||||||
|
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
||||||
|
FROM tradein_users
|
||||||
|
WHERE role = 'employee'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/employees", response_model=list[EmployeeOut])
|
@router.get("/employees", response_model=list[EmployeeOut])
|
||||||
async def list_employees(
|
async def list_employees(
|
||||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
manager_id: Annotated[int | None, Query()] = None,
|
manager_id: Annotated[int | None, Query()] = None,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
) -> list[EmployeeOut]:
|
) -> list[EmployeeOut]:
|
||||||
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=."""
|
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
|
||||||
params: dict[str, Any] = {}
|
|
||||||
where = "WHERE role = 'employee'"
|
|
||||||
if actor.role == "manager":
|
|
||||||
where += " AND manager_id = :manager_id"
|
|
||||||
params["manager_id"] = actor.user_id
|
|
||||||
elif manager_id is not None:
|
|
||||||
where += " AND manager_id = :manager_id"
|
|
||||||
params["manager_id"] = manager_id
|
|
||||||
|
|
||||||
|
Квота — ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1
|
||||||
|
(Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
|
||||||
|
"""
|
||||||
|
if actor.role == "manager":
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
_LIST_EMPLOYEES_BY_MANAGER_SQL,
|
||||||
f"""
|
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
|
||||||
SELECT id, username, display_name, org_name, email, is_active,
|
|
||||||
manager_id, created_at
|
|
||||||
FROM tradein_users
|
|
||||||
{where}
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
params,
|
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
elif manager_id is not None:
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
_LIST_EMPLOYEES_BY_MANAGER_SQL,
|
||||||
|
{"manager_id": manager_id, "limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = (
|
||||||
|
db.execute(_LIST_EMPLOYEES_ALL_SQL, {"limit": limit, "offset": offset}).mappings().all()
|
||||||
|
)
|
||||||
|
|
||||||
result: list[EmployeeOut] = []
|
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
|
||||||
for row in rows:
|
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
|
||||||
quota = account_quota.get_status(db, row["username"])
|
|
||||||
result.append(_employee_out(row, quota))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class _Store:
|
||||||
self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields
|
self.estimates: dict[str, dict[str, Any]] = {} # estimate_id -> result fields
|
||||||
self.events: list[dict[str, Any]] = [] # user_events rows (history source)
|
self.events: list[dict[str, Any]] = [] # user_events rows (history source)
|
||||||
self._next_id = 1
|
self._next_id = 1
|
||||||
|
self.query_count = 0 # db.execute() calls — N+1 regression guard (review PR #2563)
|
||||||
|
|
||||||
def add_user(
|
def add_user(
|
||||||
self,
|
self,
|
||||||
|
|
@ -169,6 +170,7 @@ class _FakeDB:
|
||||||
sql = str(stmt)
|
sql = str(stmt)
|
||||||
p = params or {}
|
p = params or {}
|
||||||
s = self.store
|
s = self.store
|
||||||
|
s.query_count += 1
|
||||||
|
|
||||||
# ---- tradein_sessions ----
|
# ---- tradein_sessions ----
|
||||||
if "INSERT INTO tradein_sessions" in sql:
|
if "INSERT INTO tradein_sessions" in sql:
|
||||||
|
|
@ -258,6 +260,8 @@ class _FakeDB:
|
||||||
if "manager_id" in p:
|
if "manager_id" in p:
|
||||||
rows = [u for u in rows if u["manager_id"] == p["manager_id"]]
|
rows = [u for u in rows if u["manager_id"] == p["manager_id"]]
|
||||||
rows = sorted(rows, key=lambda u: u["created_at"], reverse=True)
|
rows = sorted(rows, key=lambda u: u["created_at"], reverse=True)
|
||||||
|
offset, limit = p.get("offset", 0), p.get("limit", len(rows))
|
||||||
|
rows = rows[offset : offset + limit]
|
||||||
return _Result(
|
return _Result(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
@ -315,15 +319,29 @@ class _FakeDB:
|
||||||
user["password_hash"] = p["password_hash"]
|
user["password_hash"] = p["password_hash"]
|
||||||
return _Result([])
|
return _Result([])
|
||||||
|
|
||||||
# ---- account_quota_overrides upsert ----
|
# ---- account_quota_overrides upsert: unlimited always reset to False,
|
||||||
|
# note preserved (COALESCE) if a row already existed — mirrors real SQL.
|
||||||
if "INSERT INTO account_quota_overrides" in sql:
|
if "INSERT INTO account_quota_overrides" in sql:
|
||||||
|
existing_override = s.quota_overrides.get(p["username"])
|
||||||
|
preserved_note = (
|
||||||
|
existing_override["note"] if existing_override is not None else None
|
||||||
|
) or p["note"]
|
||||||
s.quota_overrides[p["username"]] = {
|
s.quota_overrides[p["username"]] = {
|
||||||
"monthly_limit": p["monthly_limit"],
|
"monthly_limit": p["monthly_limit"],
|
||||||
"unlimited": False,
|
"unlimited": False,
|
||||||
"note": p["note"],
|
"note": preserved_note,
|
||||||
}
|
}
|
||||||
return _Result([])
|
return _Result([])
|
||||||
|
|
||||||
|
# ---- account_quota_overrides: batch (list_employees) ----
|
||||||
|
if "SELECT username, monthly_limit, unlimited" in sql:
|
||||||
|
rows = [
|
||||||
|
{"username": u, "monthly_limit": ov["monthly_limit"], "unlimited": ov["unlimited"]}
|
||||||
|
for u, ov in s.quota_overrides.items()
|
||||||
|
if u in p["usernames"]
|
||||||
|
]
|
||||||
|
return _Result(rows)
|
||||||
|
|
||||||
if "SELECT unlimited FROM account_quota_overrides" in sql:
|
if "SELECT unlimited FROM account_quota_overrides" in sql:
|
||||||
override = s.quota_overrides.get(p["u"])
|
override = s.quota_overrides.get(p["u"])
|
||||||
return _Result([{"unlimited": override["unlimited"]}] if override else [])
|
return _Result([{"unlimited": override["unlimited"]}] if override else [])
|
||||||
|
|
@ -332,7 +350,16 @@ class _FakeDB:
|
||||||
override = s.quota_overrides.get(p["u"])
|
override = s.quota_overrides.get(p["u"])
|
||||||
return _Result([{"monthly_limit": override["monthly_limit"]}] if override else [])
|
return _Result([{"monthly_limit": override["monthly_limit"]}] if override else [])
|
||||||
|
|
||||||
# ---- account_estimate_usage ----
|
# ---- account_estimate_usage: batch (list_employees) ----
|
||||||
|
if "SELECT username, used" in sql:
|
||||||
|
rows = [
|
||||||
|
{"username": u, "used": used}
|
||||||
|
for (u, period), used in s.usage.items()
|
||||||
|
if u in p["usernames"] and period == p["period"]
|
||||||
|
]
|
||||||
|
return _Result(rows)
|
||||||
|
|
||||||
|
# ---- account_estimate_usage: single (account_quota.get_status) ----
|
||||||
if "SELECT used FROM account_estimate_usage" in sql:
|
if "SELECT used FROM account_estimate_usage" in sql:
|
||||||
used = s.usage.get((p["u"], p["p"]))
|
used = s.usage.get((p["u"], p["p"]))
|
||||||
return _Result([{"used": used}] if used is not None else [])
|
return _Result([{"used": used}] if used is not None else [])
|
||||||
|
|
@ -619,6 +646,28 @@ def test_block_employee_revokes_sessions(client: TestClient, store: _Store) -> N
|
||||||
assert any(e["event_type"] == "employee_blocked" for e in _EVENTS)
|
assert any(e["event_type"] == "employee_blocked" for e in _EVENTS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_password_revokes_old_sessions(client: TestClient, store: _Store) -> None:
|
||||||
|
"""HIGH (deep-review PR #2563): смена пароля обязана ревокать ВСЕ существующие
|
||||||
|
сессии сотрудника — иначе скомпрометированная/чужая сессия переживает reset
|
||||||
|
(sliding-refresh в auth_session.py продлевает её бесконечно, а не «до TTL»)."""
|
||||||
|
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
|
emp_id = store.add_user(
|
||||||
|
"emp_a", hash_password("OldSecret1!"), role="employee", manager_id=mgr_id
|
||||||
|
)
|
||||||
|
|
||||||
|
emp_client = TestClient(client.app, base_url="https://testserver")
|
||||||
|
_login(emp_client, "emp_a", "OldSecret1!")
|
||||||
|
assert any(sess["user_id"] == emp_id for sess in store.sessions.values())
|
||||||
|
|
||||||
|
_login(client, "mgr_a", "Secret123!")
|
||||||
|
resp = client.patch(f"/api/v1/team/employees/{emp_id}", json={"new_password": "NewSecret2!"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
# Password reset must have revoked ALL of emp_a's pre-existing sessions —
|
||||||
|
# not just when is_active is explicitly set to False.
|
||||||
|
assert not any(sess["user_id"] == emp_id for sess in store.sessions.values())
|
||||||
|
|
||||||
|
|
||||||
def test_unblock_employee_event(client: TestClient, store: _Store) -> None:
|
def test_unblock_employee_event(client: TestClient, store: _Store) -> None:
|
||||||
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
emp_id = store.add_user(
|
emp_id = store.add_user(
|
||||||
|
|
@ -700,6 +749,103 @@ def test_admin_sees_all_employees_and_filters_by_manager(client: TestClient, sto
|
||||||
assert {e["username"] for e in resp_filtered.json()} == {"emp_a"}
|
assert {e["username"] for e in resp_filtered.json()} == {"emp_a"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_employees_query_count_is_not_n_plus_1(client: TestClient, store: _Store) -> None:
|
||||||
|
"""Medium2 (deep-review PR #2563): pre-fix measured 2N+3 = 23 SQL queries for
|
||||||
|
N=10 employees (per-employee `account_quota.get_status`). Batch quota lookup
|
||||||
|
(`_batch_quota_status`) must keep the query count constant regardless of N."""
|
||||||
|
store.add_user("admin1", hash_password("Secret123!"), role="admin")
|
||||||
|
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
|
for i in range(10):
|
||||||
|
store.add_user(f"emp_{i}", hash_password("Secret123!"), role="employee", manager_id=mgr_id)
|
||||||
|
|
||||||
|
_login(client, "admin1", "Secret123!")
|
||||||
|
store.query_count = 0 # reset after login's own DB traffic
|
||||||
|
resp = client.get("/api/v1/team/employees")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 10
|
||||||
|
# rbac_guard session lookup (1) + current_team_actor session lookup (1) +
|
||||||
|
# list query (1) + 2 batch quota queries = 5, flat regardless of N.
|
||||||
|
assert store.query_count <= 5, f"expected O(1) queries for N=10, got {store.query_count}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_employees_pagination(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("admin1", hash_password("Secret123!"), role="admin")
|
||||||
|
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
|
for i in range(5):
|
||||||
|
store.add_user(f"emp_{i}", hash_password("Secret123!"), role="employee", manager_id=mgr_id)
|
||||||
|
|
||||||
|
_login(client, "admin1", "Secret123!")
|
||||||
|
resp = client.get("/api/v1/team/employees", params={"limit": 2, "offset": 1})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_employees_limit_max_200(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("admin1", hash_password("Secret123!"), role="admin")
|
||||||
|
_login(client, "admin1", "Secret123!")
|
||||||
|
resp = client.get("/api/v1/team/employees", params={"limit": 500})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSRF defense-in-depth — Origin/Referer check on state-changing team routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_employee_origin_mismatch_403(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
|
_login(client, "mgr_a", "Secret123!")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/team/employees",
|
||||||
|
json={"username": "emp_evil", "password": "Secret123!"},
|
||||||
|
headers={"Origin": "https://evil.example"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert "emp_evil" not in store.users
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_employee_origin_allowed(client: TestClient, store: _Store) -> None:
|
||||||
|
store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
|
_login(client, "mgr_a", "Secret123!")
|
||||||
|
|
||||||
|
allowed_origin = config.settings.cors_origins[0]
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/team/employees",
|
||||||
|
json={"username": "emp_ok", "password": "Secret123!"},
|
||||||
|
headers={"Origin": allowed_origin},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_employee_no_origin_no_referer_allowed(client: TestClient, store: _Store) -> None:
|
||||||
|
"""curl-смоук внутри контейнера не шлёт ни Origin, ни Referer — не должен ломаться."""
|
||||||
|
store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
|
_login(client, "mgr_a", "Secret123!")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/team/employees",
|
||||||
|
json={"username": "emp_curl", "password": "Secret123!"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_employee_origin_mismatch_403(client: TestClient, store: _Store) -> None:
|
||||||
|
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
|
emp_id = store.add_user(
|
||||||
|
"emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id
|
||||||
|
)
|
||||||
|
_login(client, "mgr_a", "Secret123!")
|
||||||
|
|
||||||
|
resp = client.patch(
|
||||||
|
f"/api/v1/team/employees/{emp_id}",
|
||||||
|
json={"display_name": "hacked"},
|
||||||
|
headers={"Referer": "https://evil.example/csrf.html"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert store.users["emp_a"]["display_name"] != "hacked"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# GET /employees/{id}/history
|
# GET /employees/{id}/history
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue