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:
+59
-35
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user