Security fixes: P0-P2 complete (22 fixes)
P0 (7): Auth-bypass removed, migrations fixed, plugin-upload disabled, RLS FORCE+WITH CHECK, plugin double-registration fixed, persistent volume, domain removed P1 (11): User/tenant model, Redis centralized, worker separated, transactional outbox, XSS fixed, DMS chunked streaming, permissions unified, password reset, metrics secured, config/docs fixed, cross-tenant FK P2 (4): Contact model normalized, cross-imports reduced 94%, commands+state machines for contacts/dms/mail/calendar, SPA path-traversal 8 new migrations, 99 unit tests, 13 commands, 8 contracts, 72 files changed
This commit is contained in:
+54
-2
@@ -8,6 +8,8 @@ import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import logging
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,10 +18,53 @@ from app.config import get_settings
|
||||
from app.models.session import Session as SessionModel
|
||||
from app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pwd_context = CryptContext(
|
||||
schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=get_settings().bcrypt_rounds
|
||||
)
|
||||
|
||||
# ── Global Redis client singleton ────────────────────────────────────────────
|
||||
_redis_client: aioredis.Redis | None = None
|
||||
|
||||
|
||||
async def init_redis() -> aioredis.Redis:
|
||||
"""Create and store the global Redis client. Called once during app lifespan startup."""
|
||||
global _redis_client
|
||||
if _redis_client is not None:
|
||||
logger.warning("init_redis() called but Redis client already initialized")
|
||||
return _redis_client
|
||||
_redis_client = aioredis.from_url(
|
||||
get_settings().redis_url, decode_responses=True
|
||||
)
|
||||
logger.info("Global Redis client initialized")
|
||||
return _redis_client
|
||||
|
||||
|
||||
async def close_redis() -> None:
|
||||
"""Close the global Redis client. Called during app lifespan shutdown."""
|
||||
global _redis_client
|
||||
if _redis_client is not None:
|
||||
await _redis_client.aclose()
|
||||
_redis_client = None
|
||||
logger.info("Global Redis client closed")
|
||||
|
||||
|
||||
def get_redis() -> aioredis.Redis:
|
||||
"""Return the global Redis client singleton.
|
||||
|
||||
If init_redis() has not been called yet (e.g. during testing or
|
||||
outside the app lifespan), a new client is created lazily so callers
|
||||
always get a working connection.
|
||||
"""
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = aioredis.from_url(
|
||||
get_settings().redis_url, decode_responses=True
|
||||
)
|
||||
logger.debug("Redis client created lazily (init_redis not called)")
|
||||
return _redis_client
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
@@ -56,9 +101,13 @@ async def create_session(
|
||||
redis: aioredis.Redis,
|
||||
user: User,
|
||||
tenant_id: uuid.UUID,
|
||||
role: str = "viewer",
|
||||
) -> tuple[str, str]:
|
||||
"""Create a session in Redis (runtime) and PostgreSQL (audit trail).
|
||||
Returns (session_id, csrf_token).
|
||||
|
||||
``role`` comes from UserTenant — the built-in role string for the
|
||||
active tenant membership.
|
||||
"""
|
||||
settings = get_settings()
|
||||
session_id = str(uuid.uuid4())
|
||||
@@ -71,7 +120,7 @@ async def create_session(
|
||||
"tenant_id": str(tenant_id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"role": role,
|
||||
"is_system_admin": user.is_system_admin,
|
||||
"csrf_token": csrf_token,
|
||||
"is_active": user.is_active,
|
||||
@@ -123,8 +172,9 @@ async def update_session_tenant(
|
||||
redis: aioredis.Redis,
|
||||
session_id: str,
|
||||
new_tenant_id: uuid.UUID,
|
||||
role: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update the active tenant in a Redis session."""
|
||||
"""Update the active tenant (and optionally role) in a Redis session."""
|
||||
import json
|
||||
|
||||
settings = get_settings()
|
||||
@@ -133,6 +183,8 @@ async def update_session_tenant(
|
||||
return None
|
||||
data = json.loads(raw)
|
||||
data["tenant_id"] = str(new_tenant_id)
|
||||
if role is not None:
|
||||
data["role"] = role
|
||||
ttl = await redis.ttl(f"session:{session_id}")
|
||||
if ttl <= 0:
|
||||
ttl = settings.session_ttl_seconds
|
||||
|
||||
Reference in New Issue
Block a user