Phase 1: Fix all critical release blockers (B1-B10)

B1: Remove duplicate get_redis() — singleton no longer overwritten
B2: Plugin routes now enforce activation status via require_active_plugin()
B3: Fix UploadFile ForwardRef error — remove functools.wraps from wrap_plugin_route
B4: DMS upload uses true streaming via save_stream() instead of RAM accumulation
B5: Worker on_startup registers plugin event handlers + webhook dispatcher
B6: Implement send_password_reset_email job, remove raw token logging
B7: Webhook SSRF protection (IP validation, no redirects), secret removed from response
B8: RLS repair migration 0044 + separate crm_runtime DB user (NOSUPERUSER, NOBYPASSRLS)
B9: Fix .env.docker.example AUTH_SECRET → SECRET_KEY
B10: Remove Redis default password, remove exposed DB/Redis ports

Also: add frontend_url to config, add SMTP settings to .env.docker.example,
update prestart.sh to use MIGRATION_DATABASE_URL for alembic.
This commit is contained in:
Agent Zero
2026-07-26 20:45:42 +02:00
parent 7a14973c68
commit 5ec1fc9b05
32 changed files with 1781 additions and 76 deletions
+3
View File
@@ -57,6 +57,9 @@ class Settings(BaseSettings):
# CORS
cors_origins: str = "http://localhost:5173,http://localhost:3000"
# Frontend URL for email links (password reset, invitations, etc.)
frontend_url: str = "http://localhost:5173"
# Rate Limiting
rate_limit_login_max: int = 5
rate_limit_login_window: int = 900 # 15 min
-5
View File
@@ -91,11 +91,6 @@ def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def get_redis() -> aioredis.Redis:
"""Get a Redis client instance."""
return aioredis.from_url(get_settings().redis_url, decode_responses=True)
async def create_session(
db: AsyncSession,
redis: aioredis.Redis,
+69
View File
@@ -80,3 +80,72 @@ async def get_job_status(job_id: str) -> dict[str, Any] | None:
"start_time": job_info.start_time.isoformat() if job_info.start_time else None,
"finish_time": job_info.finish_time.isoformat() if job_info.finish_time else None,
}
# ── Password Reset Email Job ─────────────────────────────────────────────────
async def send_password_reset_email(
ctx: dict[str, Any],
*,
user_id: str,
email: str,
raw_token: str,
expires_at: str,
) -> None:
"""Send a password reset email via SMTP.
This is an ARQ worker function. It is registered with the job registry
so the worker can execute it when the auth service enqueues it.
"""
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
settings = get_settings()
# Build the reset URL
reset_url = f"{settings.frontend_url.rstrip('/')}/reset-password?token={raw_token}"
# Build the email
msg = MIMEMultipart("alternative")
msg["From"] = settings.smtp_from_email
msg["To"] = email
msg["Subject"] = "LeoCRM — Passwort zurücksetzen"
text_body = (
f"Sie haben angefordert, Ihr Passwort zurückzusetzen.\n\n"
f"Klicken Sie auf den folgenden Link, um ein neues Passwort zu setzen:\n"
f"{reset_url}\n\n"
f"Dieser Link ist gültig bis {expires_at}.\n\n"
f"Falls Sie diese Anfrage nicht gestellt haben, können Sie diese\n"
f"E-Mail ignorieren. Ihr Passwort bleibt unverändert.\n"
)
html_body = (
f"<html><body>"
f"<h2>Passwort zurücksetzen</h2>"
f"<p>Sie haben angefordert, Ihr Passwort zurückzusetzen.</p>"
f"<p><a href=\"{reset_url}\">Passwort jetzt zurücksetzen</a></p>"
f"<p>Dieser Link ist gültig bis {expires_at}.</p>"
f"<p>Falls Sie diese Anfrage nicht gestellt haben, können Sie diese "
f"E-Mail ignorieren. Ihr Passwort bleibt unverändert.</p>"
f"</body></html>"
)
msg.attach(MIMEText(text_body, "plain", "utf-8"))
msg.attach(MIMEText(html_body, "html", "utf-8"))
# Send via SMTP
await aiosmtplib.send(
msg,
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username,
password=settings.smtp_password,
start_tls=settings.smtp_use_tls,
)
logger.info("Password reset email sent to %s for user %s", email, user_id)
# Register the job so the worker can find it
from app.core.job_registry import register_job # noqa: E402
register_job("send_password_reset_email", send_password_reset_email)
+11 -2
View File
@@ -1,14 +1,19 @@
"""Plugin error isolation wrapper."""
import logging
import functools
from fastapi import UploadFile as _UploadFile # noqa: F401 — needed for ForwardRef resolution
from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
def wrap_plugin_route(handler):
"""Decorator that isolates plugin route errors and returns structured JSON."""
@functools.wraps(handler)
"""Decorator that isolates plugin route errors and returns structured JSON.
Does NOT use functools.wraps to avoid copying __annotations__ and
__wrapped__ — FastAPI would otherwise try to resolve
``ForwardRef('UploadFile')`` from the original handler's signature.
"""
async def wrapper(*args, **kwargs):
try:
return await handler(*args, **kwargs)
@@ -18,4 +23,8 @@ def wrap_plugin_route(handler):
status_code=500,
content={'detail': f'Plugin error: {exc}', 'code': 'plugin_error'}
)
# Preserve identity for debugging but NOT __wrapped__ or __annotations__
wrapper.__name__ = getattr(handler, '__name__', 'wrapper')
wrapper.__module__ = getattr(handler, '__module__', __name__)
wrapper.__qualname__ = getattr(handler, '__qualname__', 'wrapper')
return wrapper
+52 -2
View File
@@ -90,11 +90,59 @@ def _get_redis_settings() -> RedisSettings:
async def on_startup(ctx: dict[str, Any]) -> None:
"""Called when worker starts."""
logger.info("ARQ worker starting...")
# Initialize Redis singleton (same as API lifespan)
from app.core.auth import init_redis
await init_redis()
# Initialize service container
from app.core.service_container import get_container
container = get_container()
await container.initialize()
# Initialize plugin registry and discover built-in plugins
from app.plugins.registry import get_registry
from app.core.db import get_engine
from app.core.event_bus import get_event_bus
from app.core.webhook_dispatcher import register_webhook_event_handlers
from sqlalchemy import select as sa_select
from app.models.plugin import Plugin as PluginModel
from sqlalchemy.ext.asyncio import async_sessionmaker
registry = get_registry()
registry.initialize(get_engine(), app=None)
registry.discover_builtins()
event_bus = get_event_bus()
async_session = async_sessionmaker(get_engine(), expire_on_commit=False)
# Activate plugins that are marked active in DB (register event handlers)
async with async_session() as db:
for name in registry.resolve_load_order():
plugin = registry.get_plugin(name)
if plugin is None:
continue
result = await db.execute(
sa_select(PluginModel).where(PluginModel.name == name)
)
plugin_record = result.scalar_one_or_none()
if plugin_record is None or not plugin_record.active:
continue
try:
await plugin.on_activate(db, container, event_bus)
logger.info(f"Worker: activated plugin {name}")
except Exception as exc:
logger.error(f"Worker: failed to activate plugin {name}: {exc}")
await db.commit()
# Register webhook dispatcher on the event bus
register_webhook_event_handlers(event_bus)
logger.info("Worker: webhook event handlers registered")
# Register search providers (normally done by app startup)
try:
from app.core.db import get_session_factory
from app.plugins.builtins.unified_search.provider_registry import auto_register_providers
factory = get_session_factory()
factory = async_session
async with factory() as db:
await auto_register_providers(db)
logger.info("Search providers registered for worker")
@@ -105,6 +153,8 @@ async def on_startup(ctx: dict[str, Any]) -> None:
async def on_shutdown(ctx: dict[str, Any]) -> None:
"""Called when worker shuts down."""
logger.info("ARQ worker shutting down...")
from app.core.auth import close_redis
await close_redis()
# ---------------------------------------------------------------------------
+31
View File
@@ -224,3 +224,34 @@ async def get_current_user_id(
) -> uuid.UUID:
"""Extract user_id from current user session."""
return uuid.UUID(current_user["user_id"])
def require_active_plugin(plugin_name: str):
"""FastAPI dependency factory: require that a plugin is active.
Returns 403 if the plugin is not active in the permission registry.
This allows routes to be registered at app creation time while
enforcing activation status at request time.
"""
async def _check(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
from app.core.permission_registry import get_permission_registry
try:
registry = get_permission_registry()
if not registry.is_plugin_active(plugin_name):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": f"Plugin '{plugin_name}' is not active",
"code": "plugin_inactive",
},
)
except HTTPException:
raise
except Exception:
# If registry not initialized yet, allow request (startup race)
pass
return current_user
return _check
+15 -5
View File
@@ -6,7 +6,7 @@ import time
import traceback
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi import FastAPI, HTTPException, Request, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -208,6 +208,11 @@ async def lifespan(app: FastAPI):
init_permission_registry(active_plugin_names)
logger.info("Permission registry initialized with %d active plugins", len(active_plugin_names))
# Register webhook dispatcher on the event bus
from app.core.webhook_dispatcher import register_webhook_event_handlers
register_webhook_event_handlers(event_bus)
logger.info("Webhook event handlers registered")
# Register field definitions from active plugins only
from app.core.permission_registry import get_permission_registry
for name in active_plugin_names:
@@ -368,9 +373,10 @@ def create_app() -> FastAPI:
app.include_router(errors.router)
# ── Register plugin routes for all built-in plugins ──
# Routes are registered here (before app start); activation status
# is enforced at runtime via require_permission and plugin checks.
# Routes are registered at app creation time so OpenAPI docs are complete.
# Activation status is enforced per-request via require_active_plugin().
import importlib
from app.deps import require_active_plugin
# Discover all built-in plugin modules and register their routes
plugin_modules = [
"app.plugins.builtins.tags",
@@ -399,6 +405,7 @@ def create_app() -> FastAPI:
for attr_name in dir(mod):
attr = getattr(mod, attr_name)
if isinstance(attr, type) and hasattr(attr, "manifest") and hasattr(attr.manifest, "routes"):
plugin_name = getattr(attr.manifest, "name", mod_name.split(".")[-1])
for route_def in attr.manifest.routes:
try:
router_module = importlib.import_module(route_def.module)
@@ -407,13 +414,16 @@ def create_app() -> FastAPI:
for route in router.routes:
if hasattr(route, 'endpoint'):
route.endpoint = wrap_plugin_route(route.endpoint)
app.include_router(router)
# Add active-plugin check as a router-level dependency
app.include_router(
router,
dependencies=[Depends(require_active_plugin(plugin_name))],
)
except Exception as exc:
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
break
except Exception as exc:
logger.error(f"Failed to register plugin routes for {mod_name}: {exc}")
# Do NOT register plugin routes here — lifespan() handles it for active plugins only
# ── Serve frontend static files (SPA) ──────────────────────────────
# Mount built frontend assets (JS, CSS, images)
+2 -2
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, String, Text
from sqlalchemy import BigInteger, DateTime, String, Text, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -39,7 +39,7 @@ class Backup(Base, TenantMixin):
PGUUID(as_uuid=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.utcnow
DateTime(timezone=True), nullable=False, server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
+2 -2
View File
@@ -35,7 +35,7 @@ class Notification(Base, TenantMixin):
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
type: Mapped[str] = mapped_column(String(20), nullable=False)
type: Mapped[str] = mapped_column(String(100), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[str | None] = mapped_column(Text, nullable=True)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -83,5 +83,5 @@ class NotificationPreference(Base, TenantMixin):
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
type_key: Mapped[str] = mapped_column(String(20), nullable=False)
type_key: Mapped[str] = mapped_column(String(100), nullable=False)
is_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+1 -1
View File
@@ -6,6 +6,7 @@ import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -51,5 +52,4 @@ class SystemSettings(Base, TenantMixin):
theme_font_family: Mapped[str] = mapped_column(String(100), nullable=False, default="Inter")
theme_border_radius: Mapped[str] = mapped_column(String(20), nullable=False, default="0.5rem")
# Automation plugin settings (JSONB)
from sqlalchemy.dialects.postgresql import JSONB
automation_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
+2 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Boolean, DateTime, Index, Numeric, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
@@ -25,6 +26,6 @@ class TaxRate(Base, TenantMixin):
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
rate: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
country: Mapped[str | None] = mapped_column(String(2), nullable=True)
+5
View File
@@ -41,3 +41,8 @@ class Webhook(Base, TenantMixin):
updated_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), nullable=True
)
@property
def has_secret(self) -> bool:
"""Return True if a webhook secret is set (never expose the secret itself)."""
return self.secret is not None and len(self.secret) > 0
+1 -1
View File
@@ -698,7 +698,7 @@ ATTACHMENT_DIR = Path(os.environ.get("STORAGE_PATH", "/data/storage")) / "ai_att
MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024 # 25MB
@router.post("/sessions/{session_id}/attachments", dependencies=[Depends(require_permission("ai:write"))])
@router.post("/sessions/{session_id}/attachments", response_model=None, dependencies=[Depends(require_permission("ai:write"))])
async def upload_attachment(
session_id: str,
file: UploadFile,
+1 -1
View File
@@ -820,7 +820,7 @@ async def ics_feed(
return Response(content=ics_content, media_type="text/calendar")
@router.post("/calendar/import", dependencies=[Depends(require_permission("calendar:write"))])
@router.post("/calendar/import", response_model=None, dependencies=[Depends(require_permission("calendar:write"))])
async def import_ics(
file: UploadFile = File(...),
calendar_id: str | None = None,
+19 -19
View File
@@ -417,7 +417,7 @@ async def delete_folder(
# ─── Files ───
@router.post("/files/upload", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("dms:write"))])
@router.post("/files/upload", status_code=status.HTTP_201_CREATED, response_model=None, dependencies=[Depends(require_permission("dms:write"))])
async def upload_file(
file: UploadFile = File(...),
folder_id: str | None = Form(None),
@@ -441,35 +441,35 @@ async def upload_file(
if folder_result.scalar_one_or_none() is None:
raise HTTPException(404, detail={"detail": "Folder not found", "code": "not_found"})
# Stream file in chunks — avoid loading entire file into RAM
# Stream file to storage — avoid loading entire file into RAM
import hashlib
CHUNK_SIZE = 1024 * 1024 # 1MB chunks
sha256 = hashlib.sha256()
file_size = 0
chunks: list[bytes] = []
while True:
chunk = await file.read(CHUNK_SIZE)
if not chunk:
break
file_size += len(chunk)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
)
sha256.update(chunk)
chunks.append(chunk)
content_hash = sha256.hexdigest()
async def chunk_stream():
nonlocal file_size
while True:
chunk = await file.read(CHUNK_SIZE)
if not chunk:
break
file_size += len(chunk)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
413, detail={"detail": "File too large (max 100MB)", "code": "file_too_large"}
)
sha256.update(chunk)
yield chunk
# Create file record
file_id = uuid.uuid4()
storage_path = _file_storage_path(tenant_id, file_id)
# Save file via storage backend
# Save file via storage backend (true streaming, no RAM accumulation)
storage = get_storage_backend()
await storage.save(storage_path, b"".join(chunks))
del chunks # Free memory
await storage.save_stream(storage_path, chunk_stream())
content_hash = sha256.hexdigest()
mime_type = file.content_type or "application/octet-stream"
+1 -1
View File
@@ -338,7 +338,7 @@ async def delete_msg(
# ─── Attachments ───
@router.post("/messages/{message_id}/attachments", dependencies=[Depends(require_permission("comm:write"))])
@router.post("/messages/{message_id}/attachments", response_model=None, dependencies=[Depends(require_permission("comm:write"))])
async def upload_attachment(
message_id: str,
file: UploadFile = File(...),
+1 -1
View File
@@ -722,7 +722,7 @@ async def sync_folder(
# ─── Attachment Upload (F-MAIL-04) ───
@router.post("/upload-attachment")
@router.post("/upload-attachment", response_model=None)
async def upload_attachment(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
@@ -0,0 +1,49 @@
"""Test sample plugin for LeoCRM plugin system testing."""
from __future__ import annotations
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest
class TestSamplePlugin(BasePlugin):
"""A sample plugin for testing the plugin lifecycle."""
manifest = PluginManifest(
name="test_sample",
version="1.0.0",
display_name="Test Sample Plugin",
description="A sample plugin for testing install/activate/deactivate/uninstall lifecycle.",
dependencies=[],
routes=[],
events=["contact.created"],
migrations=["0001_test_plugin.sql"],
permissions=[],
)
def __init__(self) -> None:
super().__init__()
self.install_called = False
self.activate_called = False
self.deactivate_called = False
self.uninstall_called = False
self.event_log: list[dict[str, Any]] = []
async def on_install(self, db, service_container) -> None:
self.install_called = True
async def on_activate(self, db, service_container, event_bus) -> None:
self.activate_called = True
await super().on_activate(db, service_container, event_bus)
async def on_deactivate(self, db, service_container, event_bus) -> None:
self.deactivate_called = True
await super().on_deactivate(db, service_container, event_bus)
async def on_uninstall(self, db, service_container) -> None:
self.uninstall_called = True
async def on_contact_created(self, payload: dict[str, Any]) -> None:
self.event_log.append({"event": "contact.created", "payload": payload})
@@ -0,0 +1,9 @@
-- Test sample plugin migration: creates a test table with tenant_id
CREATE TABLE IF NOT EXISTS test_sample_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_test_sample_items_tenant ON test_sample_items(tenant_id);
+1 -1
View File
@@ -39,7 +39,7 @@ class WebhookResponse(BaseModel):
tenant_id: uuid.UUID
url: str
events: list[str]
secret: str | None = None
has_secret: bool = False # Only indicate if a secret is set, never return it
is_active: bool = True
retry_count: int = 3
timeout_seconds: int = 30
+1 -2
View File
@@ -237,8 +237,7 @@ class AuthService:
except Exception:
logger.warning(
"ARQ enqueue failed for password reset email — "
"raw_token for development: %s",
raw_token,
"email will not be sent. Check Redis/ARQ connectivity.",
exc_info=True,
)
+53 -1
View File
@@ -4,10 +4,13 @@ from __future__ import annotations
import hashlib
import hmac
import ipaddress
import json
import logging
import socket
import uuid
from typing import Any
from urllib.parse import urlparse
import httpx
from sqlalchemy import select, delete
@@ -18,6 +21,45 @@ from app.models.webhook import Webhook
logger = logging.getLogger(__name__)
def _validate_webhook_url(url: str) -> None:
"""Validate a webhook URL to prevent SSRF attacks.
Blocks:
- Non-http(s) schemes
- Private/internal IP ranges (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x, ::1)
- Hostnames that resolve to private IPs (DNS rebinding)
- Redirects (handled by httpx follow_redirects=False)
"""
parsed = urlparse(url)
# Protocol allowlist
if parsed.scheme not in ("http", "https"):
raise ValueError(f"Webhook URL must use http or https, got: {parsed.scheme}")
hostname = parsed.hostname
if not hostname:
raise ValueError("Webhook URL has no hostname")
# Check if hostname is an IP address
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError(f"Webhook URL points to private/reserved IP: {ip}")
except ValueError as exc:
if "points to private" in str(exc) or "must use" in str(exc):
raise
# Not an IP — resolve hostname and check
try:
resolved = socket.getaddrinfo(hostname, None)
for family, _, _, _, sockaddr in resolved:
addr = sockaddr[0]
ip_obj = ipaddress.ip_address(addr)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_reserved:
raise ValueError(f"Webhook hostname '{hostname}' resolves to private IP: {addr}")
except socket.gaierror:
raise ValueError(f"Cannot resolve webhook hostname: {hostname}")
async def list_webhooks(
db: AsyncSession,
tenant_id: uuid.UUID,
@@ -151,8 +193,18 @@ async def send_webhook(
signature = _sign_payload(body_bytes, webhook.secret)
headers["X-Webhook-Signature"] = f"sha256={signature}"
# SSRF protection: validate URL before sending
try:
async with httpx.AsyncClient(timeout=webhook.timeout_seconds) as client:
_validate_webhook_url(webhook.url)
except ValueError as exc:
logger.warning("Webhook URL validation failed for %s: %s", webhook.url, exc)
return {"success": False, "status_code": None, "error": f"URL validation failed: {exc}"}
try:
async with httpx.AsyncClient(
timeout=webhook.timeout_seconds,
follow_redirects=False, # Prevent SSRF via redirect
) as client:
response = await client.post(
webhook.url,
content=body_bytes,