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:
Agent Zero
2026-07-25 21:03:46 +02:00
parent aaa7406929
commit 727d86614e
103 changed files with 6831 additions and 1053 deletions
+54 -2
View File
@@ -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