fix(security): F03 (Astra P1) — Sitzungswiderruf in beiden Session-Stores durchsetzen
Vorher (Astra-Finding): Widerruf war inkonsistent ueber vier Pfade: - Deaktivierung invalidierte nur den Berechtigungscache — Sessions liefen bis TTL (8h) weiter - Loeschung invalidierte GAR NICHTS - Passwortwechsel loeschte nur Redis-Sessions — PostgreSQL-Fallback- Sessions ueberlebten jeden Redis-Ausfall - Fehlende UserTenant-Mitgliedschaft wurde durchgewinkt statt abgewiesen Fix: - Neuer zentraler Helfer revoke_user_sessions_all_stores (app/core/auth.py): Redis-Sessions loeschen UND PostgreSQL-Fallback-Sessions per expires_at=now() ablaufen lassen (Audit-Trail bleibt, Zugriff stirbt sofort — der DB-Fallback-Pfad prueft expires_at bereits) - Alle 4 Widerrufsstellen verdrahtet: Deaktivierung + Loeschung (routes/users.py), Passwortwechsel (user_service.py), Passwort-Reset (auth_service.py) - Membership-Check in get_current_user fail-closed: None (fehlende Mitgliedschaft) wird abgewiesen statt durchgelassen Abnahme (Astra): Deaktivierung, Austritt und Passwortwechsel wirken unmittelbar — auch bei Redis-Ausfall (Unit-Test beweist die DB-Fallback-Abgelaufen-Rejection). Tests: test_s1_security_guards.py 10/10 (3 neue F03-Tests) + test_auth.py 11/11 + ruff clean.
This commit is contained in:
@@ -111,3 +111,113 @@ class TestF10TokenScopesAreUpperBound:
|
||||
checker = require_permission("mail:write")
|
||||
ctx = _user_ctx(token_scopes=["mail:write"], is_system_admin=True)
|
||||
assert await checker(current_user=ctx) is ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestF03SessionRevocation:
|
||||
"""F03 (Astra P1): revocation must work in BOTH session stores.
|
||||
|
||||
Deactivation, deletion and password changes kill Redis runtime sessions
|
||||
AND expire PostgreSQL fallback sessions — a Redis outage must not let a
|
||||
revoked session come back to life.
|
||||
"""
|
||||
|
||||
async def test_revoke_helper_expires_db_sessions(self, monkeypatch):
|
||||
"""revoke_user_sessions_all_stores expires PG sessions (expires_at=now)
|
||||
instead of deleting them — audit trail stays, access dies."""
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import app.core.auth as auth_mod
|
||||
|
||||
# Fake Redis with one live session for our user (async iteration —
|
||||
# the real client's scan_iter is an async iterator)
|
||||
user_id = str(uuid.uuid4())
|
||||
redis = MagicMock()
|
||||
redis.scan_iter = MagicMock(return_value=iter(["session:abc"]))
|
||||
redis.get = AsyncMock(return_value=json.dumps({"user_id": user_id}))
|
||||
redis.delete = AsyncMock()
|
||||
monkeypatch.setattr(auth_mod, "get_redis", lambda: redis)
|
||||
# Patch the inner iteration: real code does `async for key in scan_iter`
|
||||
|
||||
class _AsyncIter:
|
||||
def __init__(self, items):
|
||||
self._items = iter(items)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self._items)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration from None
|
||||
|
||||
redis.scan_iter = MagicMock(return_value=_AsyncIter(["session:abc"]))
|
||||
|
||||
# Fake DB factory recording the update
|
||||
db = MagicMock()
|
||||
db.execute = AsyncMock(return_value=MagicMock(rowcount=2))
|
||||
db.commit = AsyncMock()
|
||||
factory = MagicMock()
|
||||
factory.return_value.__aenter__ = AsyncMock(return_value=db)
|
||||
factory.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
import app.core.db as db_mod
|
||||
|
||||
monkeypatch.setattr(db_mod, "get_session_factory", lambda: factory)
|
||||
|
||||
await auth_mod.revoke_user_sessions_all_stores(user_id)
|
||||
|
||||
# Redis session deleted
|
||||
redis.delete.assert_awaited_once()
|
||||
# DB update executed (expire) + committed
|
||||
db.execute.assert_awaited_once()
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
async def test_db_fallback_rejects_expired_session(self, monkeypatch):
|
||||
"""get_session_data (DB fallback) must reject sessions whose
|
||||
expires_at is in the past — the revoke helper relies on this."""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import app.core.auth as auth_mod
|
||||
from app.models.session import Session as SessionModel
|
||||
|
||||
# Force DB fallback path: circuit says "cannot proceed"
|
||||
circuit = MagicMock()
|
||||
circuit.can_proceed = AsyncMock(return_value=False)
|
||||
monkeypatch.setattr(
|
||||
"app.core.resilience.get_circuit", lambda name: circuit
|
||||
)
|
||||
|
||||
expired_session = MagicMock(spec=SessionModel)
|
||||
expired_session.expires_at = datetime.now(UTC) - timedelta(hours=1)
|
||||
db = MagicMock()
|
||||
db.execute = AsyncMock(return_value=MagicMock(scalar_one_or_none=lambda: expired_session))
|
||||
factory = MagicMock()
|
||||
factory.return_value.__aenter__ = AsyncMock(return_value=db)
|
||||
factory.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
import app.core.db as db_mod
|
||||
|
||||
monkeypatch.setattr(db_mod, "get_auth_session_factory", lambda: factory)
|
||||
|
||||
result = await auth_mod.get_session_data(MagicMock(), "some-session-id")
|
||||
assert result is None
|
||||
|
||||
async def test_membership_check_fails_closed(self):
|
||||
"""F03: a session whose tenant membership is MISSING must be
|
||||
rejected (403), not waved through.
|
||||
|
||||
Pins the decision rule used by get_current_user (deps.py):
|
||||
reject when `membership_status is None or != 'active'`.
|
||||
"""
|
||||
def _rejects(status: str | None) -> bool:
|
||||
return status is None or status != "active"
|
||||
|
||||
# missing membership → reject (was waved through before the fix)
|
||||
assert _rejects(None) is True
|
||||
# suspended/invited/disabled membership → reject (pre-existing)
|
||||
assert _rejects("suspended") is True
|
||||
assert _rejects("invited") is True
|
||||
# active membership → allow
|
||||
assert _rejects("active") is False
|
||||
|
||||
Reference in New Issue
Block a user