Merge branch 'feat/cluster-auth-handshake'
fix: неизвестный узлу логин проверяется разово, не отзывается сразу
This commit is contained in:
commit
1c91341273
3 changed files with 810 additions and 28 deletions
|
|
@ -28,7 +28,7 @@ from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
from fastapi import APIRouter, HTTPException, Request, WebSocket
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
|
@ -56,6 +56,26 @@ _vanished: set[str] = set()
|
||||||
AUTH_GENERATION_SYNC_SECONDS = 1.0
|
AUTH_GENERATION_SYNC_SECONDS = 1.0
|
||||||
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
|
AUTH_GENERATION_MAX_AGE_SECONDS = 2.0
|
||||||
_generations_synced_at: float | None = None
|
_generations_synced_at: float | None = None
|
||||||
|
# Один lock на ещё неизвестный узлу логин: параллельные HTTP и WS входы
|
||||||
|
# разделяют один запрос к БД до очередной синхронизации (lct-42). Запись
|
||||||
|
# удаляется, когда lock больше никто не ждёт: иначе словарь растёт с каждым
|
||||||
|
# логином, который узел когда-либо проверял.
|
||||||
|
_lookup_locks: dict[str, "_LoginLookup"] = {}
|
||||||
|
# Логины, которых разовая проверка не нашла в users. Держатся до следующей
|
||||||
|
# сверки: повтор старой cookie не должен давать SELECT на каждый запрос.
|
||||||
|
_missing_logins: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
class _LoginLookup:
|
||||||
|
__slots__ = ("lock", "users")
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
self.users = 0
|
||||||
|
|
||||||
|
|
||||||
|
class _AuthStateUnavailable(Exception):
|
||||||
|
"""Разовая проверка неизвестного узлу логина не смогла обратиться к БД."""
|
||||||
|
|
||||||
|
|
||||||
async def _close_revoked(ws: WebSocket) -> None:
|
async def _close_revoked(ws: WebSocket) -> None:
|
||||||
|
|
@ -98,6 +118,7 @@ def prime_generations(values: dict[str, int]) -> None:
|
||||||
global _generations_synced_at
|
global _generations_synced_at
|
||||||
_generations.clear()
|
_generations.clear()
|
||||||
_generations.update(values)
|
_generations.update(values)
|
||||||
|
_missing_logins.clear()
|
||||||
_generations_synced_at = time.monotonic()
|
_generations_synced_at = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -108,15 +129,38 @@ async def load_generations() -> None:
|
||||||
|
|
||||||
|
|
||||||
async def sync_generations() -> None:
|
async def sync_generations() -> None:
|
||||||
"""Refresh shared account epochs and close sockets revoked on peer nodes."""
|
"""Сверить версии полномочий и закрыть отозванные на другом узле сокеты."""
|
||||||
|
known_before_query = set(_generations)
|
||||||
async with get_sessionmaker()() as db:
|
async with get_sessionmaker()() as db:
|
||||||
rows = (await db.execute(select(User.login, User.auth_version))).all()
|
rows = (await db.execute(select(User.login, User.auth_version))).all()
|
||||||
current = {login: version for login, version in rows}
|
current = {login: version for login, version in rows}
|
||||||
|
# Версия узла выше БД, если отзыв не удалось записать (неудачный
|
||||||
|
# logout) или учётку пересоздали после удаления. Без записи в БД
|
||||||
|
# узлы расходятся навсегда: соседний узел выдаёт cookie со старой
|
||||||
|
# версией, а этот её отвергает. Поднимаем БД до версии узла — отзыв
|
||||||
|
# сохраняется, остальные узлы догоняют за одну сверку. При гонке со
|
||||||
|
# снимком БД уже выше, и UPDATE ничего не меняет.
|
||||||
|
ahead = {
|
||||||
|
login: _generations[login]
|
||||||
|
for login, version in current.items()
|
||||||
|
if login in _generations and _generations[login] > version
|
||||||
|
}
|
||||||
|
if ahead:
|
||||||
|
try:
|
||||||
|
for login, local in ahead.items():
|
||||||
|
await db.execute(_raise_auth_version(login, local))
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc: # noqa: BLE001 — повторим на следующей сверке
|
||||||
|
log.error("не удалось записать версию полномочий узла (%s)",
|
||||||
|
type(exc).__name__)
|
||||||
|
await db.rollback()
|
||||||
for login, version in current.items():
|
for login, version in current.items():
|
||||||
previous = _generations.get(login)
|
previous = _generations.get(login)
|
||||||
|
# Снимок БД мог устареть за время запроса; меньшая версия не должна
|
||||||
|
# отменять локальный отзыв, который уже поднял поколение.
|
||||||
if previous is None:
|
if previous is None:
|
||||||
_generations[login] = version
|
_generations[login] = version
|
||||||
elif previous != version:
|
elif version > previous:
|
||||||
invalidate_login(login, version)
|
invalidate_login(login, version)
|
||||||
# Account deletion is not exposed by the application. Still close active
|
# Account deletion is not exposed by the application. Still close active
|
||||||
# sockets if an operator removes one directly from the shared directory DB.
|
# sockets if an operator removes one directly from the shared directory DB.
|
||||||
|
|
@ -127,13 +171,88 @@ async def sync_generations() -> None:
|
||||||
# lookup fall back to 0 and re-accept cookies issued before the revocation.
|
# lookup fall back to 0 and re-accept cookies issued before the revocation.
|
||||||
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
|
synthetic = {"dev"} if get_settings().dev_auth_bypass else set()
|
||||||
_vanished.intersection_update(_generations.keys() - current.keys())
|
_vanished.intersection_update(_generations.keys() - current.keys())
|
||||||
for login in _generations.keys() - current.keys() - synthetic - _vanished:
|
# Разовая проверка могла найти учётку после снимка этого запроса.
|
||||||
|
# Её отсутствие в старом снимке не означает отзыв.
|
||||||
|
for login in known_before_query - current.keys() - synthetic - _vanished:
|
||||||
invalidate_login(login)
|
invalidate_login(login)
|
||||||
_vanished.add(login)
|
_vanished.add(login)
|
||||||
|
# Снимок только что прочитан: учётка, созданная до него, уже в кэше.
|
||||||
|
_missing_logins.clear()
|
||||||
global _generations_synced_at
|
global _generations_synced_at
|
||||||
_generations_synced_at = time.monotonic()
|
_generations_synced_at = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_auth_version(login: str, version: int):
|
||||||
|
# Только вверх: параллельная запись в БД могла уже поднять версию выше.
|
||||||
|
return (
|
||||||
|
update(User)
|
||||||
|
.where(User.login == login, User.auth_version < version)
|
||||||
|
.values(auth_version=version)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_unknown_login(login: str) -> int | None:
|
||||||
|
"""Проверить неизвестный узлу логин сразу, не дожидаясь опроса БД.
|
||||||
|
|
||||||
|
Отозванный логин остаётся в `_generations` (см. `_vanished`), поэтому
|
||||||
|
отсутствие в кэше означает, что этот узел ещё не видел учётку.
|
||||||
|
Возвращает версию или None, если строки в `users` нет. При недоступной
|
||||||
|
или зависшей БД вызывает `_AuthStateUnavailable`, чтобы вход был закрыт
|
||||||
|
с 503/1013.
|
||||||
|
"""
|
||||||
|
if login in _missing_logins:
|
||||||
|
return None
|
||||||
|
entry = _lookup_locks.get(login)
|
||||||
|
if entry is None:
|
||||||
|
entry = _lookup_locks[login] = _LoginLookup()
|
||||||
|
entry.users += 1
|
||||||
|
try:
|
||||||
|
# Тот же предел, что у сверки: при partition handshake не ждёт
|
||||||
|
# таймаута TCP, а очередь за lock не растягивает ожидание сверх него.
|
||||||
|
async with asyncio.timeout(AUTH_GENERATION_MAX_AGE_SECONDS):
|
||||||
|
async with entry.lock:
|
||||||
|
cached = _generations.get(login)
|
||||||
|
if cached is not None:
|
||||||
|
return cached # параллельный запрос уже получил версию
|
||||||
|
if login in _missing_logins:
|
||||||
|
return None
|
||||||
|
async with get_sessionmaker()() as db:
|
||||||
|
version = await db.scalar(
|
||||||
|
select(User.auth_version).where(User.login == login)
|
||||||
|
)
|
||||||
|
# Пока шёл SELECT, локальный отзыв или синхронизация могли
|
||||||
|
# записать новую версию. Старый ответ не должен вернуть
|
||||||
|
# отозванную cookie.
|
||||||
|
cached = _generations.get(login)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
if version is None:
|
||||||
|
_missing_logins.add(login)
|
||||||
|
return None
|
||||||
|
_generations[login] = version
|
||||||
|
log.warning(
|
||||||
|
"учётка %s найдена разовой проверкой до сверки узла",
|
||||||
|
login_log_marker(login),
|
||||||
|
)
|
||||||
|
return version
|
||||||
|
except Exception as exc: # noqa: BLE001 — ошибка или таймаут БД закрывают вход
|
||||||
|
log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__)
|
||||||
|
raise _AuthStateUnavailable from exc
|
||||||
|
finally:
|
||||||
|
entry.users -= 1
|
||||||
|
if entry.users == 0 and _lookup_locks.get(login) is entry:
|
||||||
|
del _lookup_locks[login]
|
||||||
|
|
||||||
|
|
||||||
|
def login_log_marker(login: str) -> str:
|
||||||
|
"""Метка логина для журнала без самого логина.
|
||||||
|
|
||||||
|
По ней кластерный смоук доказывает, что узел прошёл через разовую
|
||||||
|
проверку, а не увидел учётку обычной сверкой.
|
||||||
|
"""
|
||||||
|
return hashlib.sha256(f"lct-login:{login}".encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
|
||||||
async def watch_generations() -> None:
|
async def watch_generations() -> None:
|
||||||
"""Poll PostgreSQL once per node so remote logout/role changes close WS."""
|
"""Poll PostgreSQL once per node so remote logout/role changes close WS."""
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -151,6 +270,21 @@ async def watch_generations() -> None:
|
||||||
await asyncio.sleep(AUTH_GENERATION_SYNC_SECONDS)
|
await asyncio.sleep(AUTH_GENERATION_SYNC_SECONDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_auth_state_unavailable(scope, send) -> None:
|
||||||
|
if scope["type"] == "websocket":
|
||||||
|
await send({"type": "websocket.close", "code": 1013})
|
||||||
|
else:
|
||||||
|
await send({
|
||||||
|
"type": "http.response.start",
|
||||||
|
"status": 503,
|
||||||
|
"headers": [(b"content-type", b"application/json")],
|
||||||
|
})
|
||||||
|
await send({
|
||||||
|
"type": "http.response.body",
|
||||||
|
"body": b'{"detail":"auth_state_unavailable"}',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class AuthVersionMiddleware:
|
class AuthVersionMiddleware:
|
||||||
"""Check signed-cookie epochs against the fresh, DB-synchronized node cache."""
|
"""Check signed-cookie epochs against the fresh, DB-synchronized node cache."""
|
||||||
|
|
||||||
|
|
@ -186,22 +320,17 @@ class AuthVersionMiddleware:
|
||||||
synced_at is None
|
synced_at is None
|
||||||
or time.monotonic() - synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
|
or time.monotonic() - synced_at > AUTH_GENERATION_MAX_AGE_SECONDS
|
||||||
):
|
):
|
||||||
if scope["type"] == "websocket":
|
await _send_auth_state_unavailable(scope, send)
|
||||||
await send({"type": "websocket.close", "code": 1013})
|
|
||||||
else:
|
|
||||||
await send({
|
|
||||||
"type": "http.response.start",
|
|
||||||
"status": 503,
|
|
||||||
"headers": [(b"content-type", b"application/json")],
|
|
||||||
})
|
|
||||||
await send({
|
|
||||||
"type": "http.response.body",
|
|
||||||
"body": b'{"detail":"auth_state_unavailable"}',
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
|
|
||||||
cookie_version = session.get("auth_generation")
|
cookie_version = session.get("auth_generation")
|
||||||
version = _generations.get(login)
|
version = _generations.get(login)
|
||||||
|
if version is None:
|
||||||
|
try:
|
||||||
|
version = await _resolve_unknown_login(login)
|
||||||
|
except _AuthStateUnavailable:
|
||||||
|
await _send_auth_state_unavailable(scope, send)
|
||||||
|
return
|
||||||
if version is None or cookie_version != version:
|
if version is None or cookie_version != version:
|
||||||
if session is not None:
|
if session is not None:
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|
@ -536,7 +665,20 @@ async def login(payload: LoginIn, request: Request) -> dict:
|
||||||
await audit_required(user.login, user.role, "login.blocked")
|
await audit_required(user.login, user.role, "login.blocked")
|
||||||
raise HTTPException(status_code=403, detail="blocked")
|
raise HTTPException(status_code=403, detail="blocked")
|
||||||
|
|
||||||
if _generations.get(user.login) != user.auth_version:
|
local_version = _generations.get(user.login)
|
||||||
|
if local_version is not None and local_version > user.auth_version:
|
||||||
|
# Отзыв на этом узле не дошёл до БД (неудачный logout). Сброс к версии
|
||||||
|
# БД вернул бы силу cookie, выданной до выхода; выдача cookie с
|
||||||
|
# версией узла без записи в БД развела бы узлы. Поднимаем БД.
|
||||||
|
try:
|
||||||
|
async with get_sessionmaker()() as db:
|
||||||
|
await db.execute(_raise_auth_version(user.login, local_version))
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc: # noqa: BLE001 — cookie без записанной версии не выдаём
|
||||||
|
log.error("вход: версию полномочий не удалось записать (%s)",
|
||||||
|
type(exc).__name__)
|
||||||
|
raise HTTPException(status_code=503, detail="auth_state_unavailable") from exc
|
||||||
|
elif local_version != user.auth_version:
|
||||||
invalidate_login(user.login, user.auth_version)
|
invalidate_login(user.login, user.auth_version)
|
||||||
|
|
||||||
who = Principal(
|
who = Principal(
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,229 @@ def test_generation_sync_preserves_synthetic_dev_account(monkeypatch):
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stale_generation_snapshot_does_not_revoke_newly_resolved_login(monkeypatch):
|
||||||
|
snapshot_read = asyncio.Event()
|
||||||
|
release_snapshot = asyncio.Event()
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def all(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def execute(self, _query):
|
||||||
|
snapshot_read.set()
|
||||||
|
await release_snapshot.wait()
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
async def scalar(self, _query):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth.prime_generations({})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
sync = asyncio.create_task(auth.sync_generations())
|
||||||
|
await snapshot_read.wait()
|
||||||
|
assert await auth._resolve_unknown_login("just-created") == 0
|
||||||
|
release_snapshot.set()
|
||||||
|
await sync
|
||||||
|
assert auth._generations["just-created"] == 0
|
||||||
|
assert "just-created" not in auth._vanished
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stale_generation_snapshot_does_not_restore_revoked_cookie(monkeypatch):
|
||||||
|
snapshot_read = asyncio.Event()
|
||||||
|
release_snapshot = asyncio.Event()
|
||||||
|
|
||||||
|
raised = []
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def all(self):
|
||||||
|
return [("revoked", 0)]
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def execute(self, query):
|
||||||
|
if query.is_update:
|
||||||
|
raised.append(query.compile().params)
|
||||||
|
return None
|
||||||
|
snapshot_read.set()
|
||||||
|
await release_snapshot.wait()
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
auth.prime_generations({"revoked": 0})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
sync = asyncio.create_task(auth.sync_generations())
|
||||||
|
await snapshot_read.wait()
|
||||||
|
auth.invalidate_login("revoked", 1)
|
||||||
|
release_snapshot.set()
|
||||||
|
await sync
|
||||||
|
assert auth._generations["revoked"] == 1
|
||||||
|
# В БД версия уже 1 (отзыв записан до invalidate); UPDATE «только вверх»
|
||||||
|
# в реальной БД ничего не изменит, в снимке же он выглядит как отставание.
|
||||||
|
assert [params["auth_version"] for params in raised] == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def _versioned_users_db(versions: dict[str, int]):
|
||||||
|
"""Поддельная users: сверка читает версии, UPDATE поднимает их только вверх."""
|
||||||
|
updates = []
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def all(self):
|
||||||
|
return list(versions.items())
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def execute(self, query):
|
||||||
|
if query.is_update:
|
||||||
|
params = query.compile().params
|
||||||
|
login = next(v for k, v in params.items() if k.startswith("login"))
|
||||||
|
target = params["auth_version"]
|
||||||
|
updates.append((login, target))
|
||||||
|
if versions.get(login, target) < target:
|
||||||
|
versions[login] = target
|
||||||
|
return None
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
async def scalar(self, _query):
|
||||||
|
raise AssertionError("login is cached, no one-shot lookup expected")
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def rollback(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return updates, lambda: lambda: FakeSession()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_logout_revocation_is_written_to_db_so_nodes_converge(monkeypatch):
|
||||||
|
"""Неудачный logout поднял версию только на этом узле (B). Сверка не
|
||||||
|
откатывает отзыв, а записывает его в БД: соседний узел A догоняет, и вход
|
||||||
|
на A после этого выдаёт cookie, которую B принимает."""
|
||||||
|
login = "failed-logout-user"
|
||||||
|
versions = {login: 4}
|
||||||
|
updates, sessionmaker = _versioned_users_db(versions)
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
|
||||||
|
auth.prime_generations({login: 4})
|
||||||
|
try:
|
||||||
|
auth.invalidate_login(login) # ветка except в logout: БД недоступна
|
||||||
|
assert auth._generations[login] == 5
|
||||||
|
|
||||||
|
await auth.sync_generations()
|
||||||
|
assert updates == [(login, 5)]
|
||||||
|
assert versions[login] == 5, "revocation must reach the shared DB"
|
||||||
|
assert auth._generations[login] == 5, "sync must not roll back a local revocation"
|
||||||
|
|
||||||
|
# Вход на A берёт версию из БД, и теперь она совпадает с версией B.
|
||||||
|
assert versions[login] == auth._generations[login]
|
||||||
|
|
||||||
|
await auth.sync_generations()
|
||||||
|
assert updates == [(login, 5)], "converged nodes must not write again"
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recreated_account_is_raised_to_the_node_revocation_version(monkeypatch):
|
||||||
|
"""Учётку удалили (узел отозвал её, поколение +1) и создали заново с 0.
|
||||||
|
Без записи в БД узел остался бы впереди навсегда."""
|
||||||
|
login = "recreated-user"
|
||||||
|
versions = {}
|
||||||
|
updates, sessionmaker = _versioned_users_db(versions)
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", sessionmaker)
|
||||||
|
auth.prime_generations({login: 2})
|
||||||
|
try:
|
||||||
|
await auth.sync_generations()
|
||||||
|
assert auth._generations[login] == 3
|
||||||
|
assert login in auth._vanished
|
||||||
|
|
||||||
|
versions[login] = 0 # оператор создал учётку заново
|
||||||
|
await auth.sync_generations()
|
||||||
|
assert updates == [(login, 3)]
|
||||||
|
assert versions[login] == 3
|
||||||
|
assert login not in auth._vanished
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
auth._vanished.discard(login)
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_raises_db_version_when_node_revocation_was_not_persisted(client, monkeypatch):
|
||||||
|
"""Вход на узле, где отзыв не дошёл до БД: cookie получает версию узла,
|
||||||
|
а БД поднимается до неё. Сброс к версии БД вернул бы силу старой cookie."""
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
user = SimpleNamespace(
|
||||||
|
login="ahead-login", auth_provider="local", password_hash="hash",
|
||||||
|
blocked=False, role="instructor", full_name="Преподаватель",
|
||||||
|
service=None, trainee_id=None, auth_version=1,
|
||||||
|
)
|
||||||
|
updates = []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _statement):
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def execute(self, query):
|
||||||
|
assert query.is_update
|
||||||
|
updates.append(query.compile().params["auth_version"])
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def audit_ok(*_args, **_kwargs):
|
||||||
|
return True
|
||||||
|
|
||||||
|
settings = get_settings().model_copy(update={"demo_no_db": False, "ldap_enabled": False})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
monkeypatch.setattr(auth, "verify_password", lambda *_args: True)
|
||||||
|
monkeypatch.setattr(auth, "audit", audit_ok)
|
||||||
|
auth._generations[user.login] = 2 # неудачный logout на этом узле
|
||||||
|
try:
|
||||||
|
response = client.post("/api/auth/login", json={"login": user.login, "password": "x"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert updates == [2]
|
||||||
|
assert auth._generations[user.login] == 2, "local revocation must not be rolled back"
|
||||||
|
assert client.get("/api/auth/me").status_code == 200
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(user.login, None)
|
||||||
|
|
||||||
|
|
||||||
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
|
def test_cross_origin_browser_websocket_is_rejected_before_handshake(client):
|
||||||
assert client.post("/api/auth/dev-token").status_code == 200
|
assert client.post("/api/auth/dev-token").status_code == 200
|
||||||
with pytest.raises(WebSocketDisconnect) as exc:
|
with pytest.raises(WebSocketDisconnect) as exc:
|
||||||
|
|
@ -410,6 +633,401 @@ def test_auth_middleware_fails_closed_when_generation_cache_is_stale_but_allows_
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_resolves_and_caches_login_unknown_to_this_node(monkeypatch):
|
||||||
|
"""Cluster handshake (lct-42): a login another node just authenticated is
|
||||||
|
fetched via a single SELECT rather than being treated as revoked, and the
|
||||||
|
result is cached so a second request for it does not query again."""
|
||||||
|
login = "peer-node-fresh-login"
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
calls.append(1)
|
||||||
|
return 5
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class InnerApp:
|
||||||
|
def __init__(self):
|
||||||
|
self.called = 0
|
||||||
|
|
||||||
|
async def __call__(self, _scope, _receive, _send):
|
||||||
|
self.called += 1
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
|
||||||
|
def make_scope():
|
||||||
|
return {
|
||||||
|
"type": "http", "path": "/api/admin/users",
|
||||||
|
"session": {
|
||||||
|
"principal": {"login": login},
|
||||||
|
"auth_instance": auth._INSTANCE,
|
||||||
|
"auth_generation": 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
async def send(_message):
|
||||||
|
return None
|
||||||
|
|
||||||
|
first = InnerApp()
|
||||||
|
await auth.AuthVersionMiddleware(first)(make_scope(), receive, send)
|
||||||
|
assert first.called == 1, "a fresh, valid epoch must reach the route"
|
||||||
|
assert auth._generations[login] == 5
|
||||||
|
assert calls == [1]
|
||||||
|
|
||||||
|
second = InnerApp()
|
||||||
|
await auth.AuthVersionMiddleware(second)(make_scope(), receive, send)
|
||||||
|
assert second.called == 1
|
||||||
|
assert calls == [1], "cached epoch must not trigger a second SELECT"
|
||||||
|
|
||||||
|
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
auth._lookup_locks.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_middleware_rejects_login_missing_from_users_via_one_shot_query(monkeypatch):
|
||||||
|
login = "peer-node-deleted-login"
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class InnerApp:
|
||||||
|
def __init__(self):
|
||||||
|
self.called = False
|
||||||
|
|
||||||
|
async def __call__(self, _scope, _receive, _send):
|
||||||
|
self.called = True
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
cookie_session = {
|
||||||
|
"principal": {"login": login},
|
||||||
|
"auth_instance": auth._INSTANCE,
|
||||||
|
"auth_generation": 3,
|
||||||
|
}
|
||||||
|
scope = {"type": "http", "path": "/api/admin/users", "session": cookie_session}
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
async def send(_message):
|
||||||
|
return None
|
||||||
|
|
||||||
|
protected = InnerApp()
|
||||||
|
await auth.AuthVersionMiddleware(protected)(scope, receive, send)
|
||||||
|
assert protected.called, "route still runs but the session was cleared below"
|
||||||
|
assert cookie_session == {}
|
||||||
|
assert login not in auth._generations
|
||||||
|
|
||||||
|
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._lookup_locks.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_login_lookup_does_not_clobber_a_newer_local_revocation(monkeypatch):
|
||||||
|
"""A stale SELECT reply landing after a concurrent local revoke must not
|
||||||
|
resurrect the revoked cookie's epoch (regression: lock only deduplicates
|
||||||
|
concurrent lookups, it does not order a lookup against a write)."""
|
||||||
|
login = "peer-node-race-login"
|
||||||
|
started = asyncio.Event()
|
||||||
|
resume = asyncio.Event()
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
started.set()
|
||||||
|
await resume.wait()
|
||||||
|
return 0 # the epoch as it stood before the concurrent revoke below
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
task = asyncio.ensure_future(auth._resolve_unknown_login(login))
|
||||||
|
await started.wait() # the SELECT is in flight, holding the login's lock
|
||||||
|
auth.invalidate_login(login, 1) # a local logout/edit races the reply
|
||||||
|
resume.set()
|
||||||
|
result = await task
|
||||||
|
assert result == 1, "the newer local revocation must win over the stale SELECT reply"
|
||||||
|
assert auth._generations[login] == 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
auth._lookup_locks.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_login_lookup_deduplicates_concurrent_requests_into_one_select(monkeypatch):
|
||||||
|
login = "peer-node-concurrent-login"
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
calls.append(1)
|
||||||
|
await asyncio.sleep(0.01) # widen the window for a racing second caller
|
||||||
|
return 9
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
results = await asyncio.gather(
|
||||||
|
auth._resolve_unknown_login(login),
|
||||||
|
auth._resolve_unknown_login(login),
|
||||||
|
auth._resolve_unknown_login(login),
|
||||||
|
)
|
||||||
|
assert results == [9, 9, 9]
|
||||||
|
assert calls == [1], "concurrent lookups for one login must issue a single SELECT"
|
||||||
|
assert login not in auth._lookup_locks, "lock entry must not outlive its waiters"
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
auth._lookup_locks.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_login_lookup_fails_closed_when_database_is_unreachable(monkeypatch):
|
||||||
|
login = "peer-node-db-outage-login"
|
||||||
|
|
||||||
|
class BrokenSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise OSError("database unavailable")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class InnerApp:
|
||||||
|
def __init__(self):
|
||||||
|
self.called = False
|
||||||
|
|
||||||
|
async def __call__(self, _scope, _receive, _send):
|
||||||
|
self.called = True
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
cookie_session = {
|
||||||
|
"principal": {"login": login},
|
||||||
|
"auth_instance": auth._INSTANCE,
|
||||||
|
"auth_generation": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
http_messages = []
|
||||||
|
|
||||||
|
async def http_send(message):
|
||||||
|
http_messages.append(message)
|
||||||
|
|
||||||
|
http_app = InnerApp()
|
||||||
|
await auth.AuthVersionMiddleware(http_app)(
|
||||||
|
{"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
|
||||||
|
receive, http_send,
|
||||||
|
)
|
||||||
|
assert not http_app.called
|
||||||
|
assert http_messages[0]["status"] == 503
|
||||||
|
|
||||||
|
ws_messages = []
|
||||||
|
|
||||||
|
async def ws_send(message):
|
||||||
|
ws_messages.append(message)
|
||||||
|
|
||||||
|
ws_app = InnerApp()
|
||||||
|
await auth.AuthVersionMiddleware(ws_app)(
|
||||||
|
{"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
|
||||||
|
receive, ws_send,
|
||||||
|
)
|
||||||
|
assert not ws_app.called
|
||||||
|
assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
|
||||||
|
|
||||||
|
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: BrokenSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._lookup_locks.pop(login, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_login_lookup_times_out_instead_of_hanging_on_partition(monkeypatch):
|
||||||
|
"""При partition SELECT может висеть до таймаута TCP. Разовая проверка
|
||||||
|
ограничена тем же порогом, что сверка, и закрывает вход 503 / 1013 —
|
||||||
|
и для запроса, который ждёт lock за зависшим."""
|
||||||
|
login = "peer-node-hanging-db-login"
|
||||||
|
monkeypatch.setattr(auth, "AUTH_GENERATION_MAX_AGE_SECONDS", 0.3)
|
||||||
|
|
||||||
|
class HangingDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return HangingDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class InnerApp:
|
||||||
|
called = False
|
||||||
|
|
||||||
|
async def __call__(self, _scope, _receive, _send):
|
||||||
|
self.called = True
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
cookie_session = {
|
||||||
|
"principal": {"login": login},
|
||||||
|
"auth_instance": auth._INSTANCE,
|
||||||
|
"auth_generation": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": b"", "more_body": False}
|
||||||
|
|
||||||
|
http_messages, ws_messages = [], []
|
||||||
|
|
||||||
|
async def http_send(message):
|
||||||
|
http_messages.append(message)
|
||||||
|
|
||||||
|
async def ws_send(message):
|
||||||
|
ws_messages.append(message)
|
||||||
|
|
||||||
|
http_app, ws_app = InnerApp(), InnerApp()
|
||||||
|
started = asyncio.get_running_loop().time()
|
||||||
|
await asyncio.wait_for(asyncio.gather(
|
||||||
|
auth.AuthVersionMiddleware(http_app)(
|
||||||
|
{"type": "http", "path": "/api/admin/users", "session": dict(cookie_session)},
|
||||||
|
receive, http_send,
|
||||||
|
),
|
||||||
|
auth.AuthVersionMiddleware(ws_app)(
|
||||||
|
{"type": "websocket", "path": "/ws/control/x", "session": dict(cookie_session)},
|
||||||
|
receive, ws_send,
|
||||||
|
),
|
||||||
|
), timeout=2)
|
||||||
|
elapsed = asyncio.get_running_loop().time() - started
|
||||||
|
assert elapsed < 1, "a queued handshake must not wait for a second timeout"
|
||||||
|
assert not http_app.called and not ws_app.called
|
||||||
|
assert http_messages[0]["status"] == 503
|
||||||
|
assert ws_messages[0] == {"type": "websocket.close", "code": 1013}
|
||||||
|
assert login not in auth._lookup_locks, "timed-out lookup must release its lock entry"
|
||||||
|
|
||||||
|
settings = auth.get_settings().model_copy(update={"demo_no_db": False})
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_shot_lookup_logs_login_marker_not_login(caplog, monkeypatch):
|
||||||
|
"""Кластерный смоук ищет эту метку в журнале узла B: она доказывает, что
|
||||||
|
вход прошёл через разовую проверку, а не через обычную сверку."""
|
||||||
|
login = "peer-node-logged-login"
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
assert await auth._resolve_unknown_login(login) == 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
with caplog.at_level("WARNING", logger="app.api.auth"):
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._generations.pop(login, None)
|
||||||
|
assert auth.login_log_marker(login) in caplog.text
|
||||||
|
assert login not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_login_is_cached_until_next_sync_and_lock_entries_are_released(monkeypatch):
|
||||||
|
login = "peer-node-replayed-missing-login"
|
||||||
|
lookups = []
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def all(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
class FakeDb:
|
||||||
|
async def scalar(self, _query):
|
||||||
|
lookups.append(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def execute(self, _query):
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return FakeDb()
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
auth.prime_generations({})
|
||||||
|
assert await auth._resolve_unknown_login(login) is None
|
||||||
|
assert await auth._resolve_unknown_login(login) is None
|
||||||
|
assert lookups == [1], "a replayed cookie must not query users on every request"
|
||||||
|
assert auth._lookup_locks == {}
|
||||||
|
|
||||||
|
await auth.sync_generations()
|
||||||
|
assert await auth._resolve_unknown_login(login) is None
|
||||||
|
assert lookups == [1, 1], "the negative result lives only until the next sync"
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth, "get_settings", lambda: SimpleNamespace(dev_auth_bypass=False))
|
||||||
|
monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession())
|
||||||
|
try:
|
||||||
|
asyncio.run(run())
|
||||||
|
finally:
|
||||||
|
auth._missing_logins.discard(login)
|
||||||
|
|
||||||
|
|
||||||
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
|
def test_auth_middleware_uses_fresh_generation_cache_without_per_request_database_query(monkeypatch):
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ def login(base: str, login_name: str, password: str) -> str:
|
||||||
return cookie
|
return cookie
|
||||||
|
|
||||||
|
|
||||||
async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dict) -> None:
|
async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dict) -> float:
|
||||||
async with websockets.connect(
|
async with websockets.connect(
|
||||||
f"{ws_base}/ws/control/{session_id}",
|
f"{ws_base}/ws/control/{session_id}",
|
||||||
additional_headers={"Cookie": cookie},
|
additional_headers={"Cookie": cookie},
|
||||||
|
|
@ -81,7 +81,9 @@ async def send_control(ws_base: str, session_id: UUID, cookie: str, payload: dic
|
||||||
ping_interval=None,
|
ping_interval=None,
|
||||||
) as socket:
|
) as socket:
|
||||||
await socket.send(json.dumps(payload, ensure_ascii=False))
|
await socket.send(json.dumps(payload, ensure_ascii=False))
|
||||||
|
sent_at = time.monotonic()
|
||||||
await asyncio.sleep(0.15)
|
await asyncio.sleep(0.15)
|
||||||
|
return sent_at
|
||||||
|
|
||||||
|
|
||||||
async def probe_ws(ws_base: str, path: str, cookie: str) -> dict:
|
async def probe_ws(ws_base: str, path: str, cookie: str) -> dict:
|
||||||
|
|
@ -238,7 +240,7 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.pool import NullPool
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.api.auth import hash_password
|
from app.api.auth import hash_password, login_log_marker
|
||||||
from app.db.models import AuditLog, Session, Trainee, User
|
from app.db.models import AuditLog, Session, Trainee, User
|
||||||
|
|
||||||
database_url = (
|
database_url = (
|
||||||
|
|
@ -251,7 +253,7 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
observer_engine = create_async_engine(database_url, poolclass=NullPool)
|
observer_engine = create_async_engine(database_url, poolclass=NullPool)
|
||||||
run_id = uuid4().hex[:12]
|
run_id = uuid4().hex[:12]
|
||||||
session_id = uuid4()
|
session_id = args.session_id or uuid4()
|
||||||
trainee = Trainee(name=f"Failover smoke {run_id}")
|
trainee = Trainee(name=f"Failover smoke {run_id}")
|
||||||
instructor_login = f"fo-i-{run_id}"
|
instructor_login = f"fo-i-{run_id}"
|
||||||
trainee_login = f"fo-t-{run_id}"
|
trainee_login = f"fo-t-{run_id}"
|
||||||
|
|
@ -263,6 +265,7 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
fault_stopped = False
|
fault_stopped = False
|
||||||
result: dict = {"session_id": str(session_id), "checks": {}}
|
result: dict = {"session_id": str(session_id), "checks": {}}
|
||||||
backend = f"http://127.0.0.1:{args.backend_port}"
|
backend = f"http://127.0.0.1:{args.backend_port}"
|
||||||
|
result["login_node"] = "backend-a"
|
||||||
ws_base = args.frontend_url.replace("https://", "wss://").replace("http://", "ws://")
|
ws_base = args.frontend_url.replace("https://", "wss://").replace("http://", "ws://")
|
||||||
instructor_cookie = trainee_cookie = None
|
instructor_cookie = trainee_cookie = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -278,21 +281,32 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
trainee_id=trainee.id, blocked=False),
|
trainee_id=trainee.id, blocked=False),
|
||||||
])
|
])
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
users_committed_at = time.monotonic()
|
||||||
|
|
||||||
instructor_cookie = login(backend, instructor_login, instructor_password)
|
instructor_cookie = login(backend, instructor_login, instructor_password)
|
||||||
trainee_cookie = login(backend, trainee_login, trainee_password)
|
trainee_cookie = login(backend, trainee_login, trainee_password)
|
||||||
# Учётки созданы прямо в БД, вход — на узле A, а сокет занятия Nginx
|
# Учётки созданы прямо в БД, вход — на узле A, а сокет занятия Nginx
|
||||||
# может отдать узлу B. B узнаёт новый логин при очередной сверке
|
# может отдать узлу B до его сверки поколений. Ждать сверку не нужно:
|
||||||
# поколений (раз в секунду); до неё middleware считает cookie отозванной.
|
# B проверяет неизвестный логин разовым SELECT (lct-42). Ожидание здесь
|
||||||
for port in (args.backend_port, args.backend_b_port):
|
# лишило бы доказательности проверку ниже: опрос B сам вызвал бы разовую
|
||||||
for cookie in (instructor_cookie, trainee_cookie):
|
# проверку, а за секунду B узнал бы логин сверкой.
|
||||||
if not await wait_auth_fresh(f"http://127.0.0.1:{port}", cookie):
|
control_sent_at = await send_control(ws_base, session_id, instructor_cookie, {
|
||||||
raise RuntimeError(f"backend :{port} не узнал новый логин за 10 с")
|
|
||||||
await send_control(ws_base, session_id, instructor_cookie, {
|
|
||||||
"type": "scenario.start", "scenario_id": args.scenario,
|
"type": "scenario.start", "scenario_id": args.scenario,
|
||||||
"trainee": trainee.name, "trainee_id": str(trainee.id),
|
"trainee": trainee.name, "trainee_id": str(trainee.id),
|
||||||
"mode": "training", "exercise": "dds",
|
"mode": "training", "exercise": "dds",
|
||||||
})
|
})
|
||||||
|
result["users_commit_to_control_seconds"] = round(control_sent_at - users_committed_at, 4)
|
||||||
|
if args.expect_initial_owner == "backend-b":
|
||||||
|
result["checks"]["control_sent_within_first_second"] = (
|
||||||
|
result["users_commit_to_control_seconds"] < 1
|
||||||
|
)
|
||||||
|
# Сверка B идёт раз в секунду в случайной фазе, и время само по себе
|
||||||
|
# не доказывает, что B не знал логин. Доказательство — запись B о
|
||||||
|
# разовой проверке именно этого логина.
|
||||||
|
result["checks"]["backend_b_resolved_login_one_shot"] = (
|
||||||
|
login_log_marker(instructor_login)
|
||||||
|
in compose(args, "logs", "--no-color", "backend-b")
|
||||||
|
)
|
||||||
|
|
||||||
async def session_row():
|
async def session_row():
|
||||||
async with factory() as db:
|
async with factory() as db:
|
||||||
|
|
@ -327,6 +341,12 @@ async def main(args: argparse.Namespace) -> int:
|
||||||
old_epoch = row.backend_fencing_epoch
|
old_epoch = row.backend_fencing_epoch
|
||||||
result["initial_owner"] = row.backend_node_id
|
result["initial_owner"] = row.backend_node_id
|
||||||
result["initial_epoch"] = old_epoch
|
result["initial_epoch"] = old_epoch
|
||||||
|
if args.expect_initial_owner and row.backend_node_id != args.expect_initial_owner:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"session routed to {row.backend_node_id}, expected {args.expect_initial_owner}"
|
||||||
|
)
|
||||||
|
if args.expect_initial_owner == "backend-b":
|
||||||
|
result["checks"]["login_a_initial_control_b"] = True
|
||||||
|
|
||||||
# Commit real DDS work before the fault. These actions must survive the
|
# Commit real DDS work before the fault. These actions must survive the
|
||||||
# checkpoint handoff and remain part of the final scored report.
|
# checkpoint handoff and remain part of the final scored report.
|
||||||
|
|
@ -735,4 +755,6 @@ if __name__ == "__main__":
|
||||||
parser.add_argument("--reconnect-attempts", type=int, default=5)
|
parser.add_argument("--reconnect-attempts", type=int, default=5)
|
||||||
parser.add_argument("--rest-timeout", type=float, default=60)
|
parser.add_argument("--rest-timeout", type=float, default=60)
|
||||||
parser.add_argument("--failure-mode", choices=("kill", "partition"), default="kill")
|
parser.add_argument("--failure-mode", choices=("kill", "partition"), default="kill")
|
||||||
|
parser.add_argument("--expect-initial-owner", choices=("backend-a", "backend-b"))
|
||||||
|
parser.add_argument("--session-id", type=UUID)
|
||||||
raise SystemExit(asyncio.run(main(parser.parse_args())))
|
raise SystemExit(asyncio.run(main(parser.parse_args())))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue