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
+59 -35
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import uuid
from typing import Any
@@ -13,6 +14,27 @@ from app.config import get_settings
from app.core.auth import get_redis, get_session_data, refresh_session_ttl
from app.core.db import get_db, set_tenant_context
logger = logging.getLogger(__name__)
# Known write-permission modules — used by require_write() to check
# specific permissions instead of broad wildcards like *:write
_WRITE_PERMISSIONS = [
"contacts:write",
"contacts:create",
"users:write",
"roles:write",
"audit:write",
"attachments:write",
"workflows:write",
"sequences:write",
"addresses:write",
"taxes:write",
"currencies:write",
"notifications:write",
"import_export:write",
"user_preferences:write",
]
async def get_redis_dep() -> aioredis.Redis:
"""FastAPI dependency for Redis client."""
@@ -24,40 +46,13 @@ async def get_current_user(
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis_dep),
) -> dict[str, Any]:
"""Get the current authenticated user from session cookie or internal headers.
"""Get the current authenticated user from session cookie.
Returns session data dict with user_id, tenant_id, email, name, role,
and resolved permissions from Redis cache.
Supports internal calls via X-Internal-Call: true header with
X-Tenant-Id and X-User-Id headers (for AI tool API access).
"""
settings = get_settings()
# Check for internal call (AI tool access)
if request.headers.get("X-Internal-Call") == "true":
tenant_id_str = request.headers.get("X-Tenant-Id", "")
user_id_str = request.headers.get("X-User-Id", "")
if tenant_id_str and user_id_str:
try:
tenant_id = uuid.UUID(tenant_id_str)
user_id = uuid.UUID(user_id_str)
await set_tenant_context(db, tenant_id)
from app.core.permissions import get_cached_permissions
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
return {
"user_id": user_id_str,
"tenant_id": tenant_id_str,
"permissions": resolved.get("permissions", []),
"denied_permissions": resolved.get("denied", []),
"field_permissions": resolved.get("field_permissions", {}),
"is_system_admin": resolved.get("is_system_admin", False),
"is_active": True,
}
except (ValueError, Exception):
pass # Fall through to session cookie auth
session_id = request.cookies.get(settings.session_cookie_name)
if not session_id:
raise HTTPException(
@@ -101,14 +96,29 @@ async def get_current_user(
async def require_admin(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Require admin role (legacy + new permission system)."""
if current_user.get("is_system_admin") or current_user.get("role") == "admin":
"""Require admin role (legacy + new permission system).
Legacy role string 'admin' is deprecated — log a warning when used.
New system uses is_system_admin or *:* permission.
"""
if current_user.get("is_system_admin"):
return current_user
# Also check via permission system
# Legacy role string fallback — deprecated
if current_user.get("role") == "admin":
logger.warning(
"Legacy role string 'admin' used for user=%s — deprecated, "
"migrate to is_system_admin or *:* permission",
current_user.get("user_id"),
)
return current_user
# New permission system check
from app.core.permissions import check_permission
if check_permission(current_user, "*:*"):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Admin access required", "code": "forbidden"},
@@ -118,17 +128,31 @@ async def require_admin(
async def require_write(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Require write permission (admin, editor, or custom role with write perms)."""
"""Require write permission (admin, editor, or custom role with write perms).
Legacy role strings 'admin'/'editor' are deprecated — log a warning when used.
New system checks specific module:write permissions instead of broad wildcards.
"""
if current_user.get("is_system_admin"):
return current_user
# Legacy role string fallback — deprecated
role = current_user.get("role", "viewer")
if role in ("admin", "editor"):
logger.warning(
"Legacy role string '%s' used for user=%s in require_write — deprecated, "
"migrate to specific module:write permissions",
role, current_user.get("user_id"),
)
return current_user
# Check via permission system for custom roles
# Check via permission system for specific write permissions
from app.core.permissions import check_permission
if check_permission(current_user, "*:write") or check_permission(current_user, "*:create"):
return current_user
for perm in _WRITE_PERMISSIONS:
if check_permission(current_user, perm):
return current_user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Write access required", "code": "forbidden"},