fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed

- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
This commit is contained in:
Agent Zero
2026-08-16 01:17:18 +02:00
parent 3d9b76cea4
commit abbe7a18fc
306 changed files with 5912 additions and 1827 deletions
+104 -122
View File
@@ -3,80 +3,77 @@
from __future__ import annotations
import asyncio
import importlib
import logging
import os
import time
import traceback
import uuid as _uuid
from contextlib import asynccontextmanager
import structlog
from fastapi import FastAPI, HTTPException, Request, Depends, APIRouter
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import WebSocketRoute
import importlib
import logging
import os
logger = logging.getLogger(__name__)
from app.config import get_settings
from app.core.db import close_engine, get_engine
from app.core.error_codes import ApiError, ErrorCategory, classify_exception, build_error_response
from app.core.middleware import CSRFMiddleware, SecurityHeadersMiddleware
from app.core.rate_limit import GeneralRateLimitMiddleware
from app.core.resilience import CircuitBreakerMiddleware
from app.core.monitoring import record_error, record_request
from app.core.plugin_error_handler import wrap_plugin_route
from app.core.service_container import get_container
from app.plugins.registry import get_registry
from app.routes import (
from app.config import get_settings # noqa: E402
from app.core.db import close_engine, get_engine # noqa: E402
from app.core.error_codes import ApiError, build_error_response # noqa: E402
from app.core.middleware import CSRFMiddleware, SecurityHeadersMiddleware # noqa: E402
from app.core.monitoring import record_error, record_request # noqa: E402
from app.core.rate_limit import GeneralRateLimitMiddleware # noqa: E402
from app.core.resilience import CircuitBreakerMiddleware # noqa: E402
from app.core.service_container import get_container # noqa: E402
from app.plugins.registry import get_registry # noqa: E402
from app.routes import ( # noqa: E402
addresses,
bank_accounts,
ai_copilot,
api_tokens,
attachments,
audit,
auth,
errors,
contact_folders,
backups,
bank_accounts,
contact_folder_permissions,
entity_permissions,
contact_folders,
contacts,
currencies,
custom_field_definitions,
custom_fields,
dashboard,
entity_history,
entity_permissions,
errors,
groups,
guests,
health,
import_export,
metrics,
notifications,
plugins,
roles,
tenants,
users,
user_preferences,
workflows,
currencies,
taxes,
sequences,
system_settings,
attachments,
custom_field_definitions,
custom_fields,
saved_filters,
workspaces,
saved_views,
webhooks,
backups,
outbox,
owner_transfer,
permission_templates,
plugins,
# delegations, # ⏸ Parked — not integrated into resolve_permissions()
policies,
guests,
outbox,
api_tokens,
roles,
saved_filters,
saved_views,
sequences,
system_settings,
taxes,
tenants,
user_preferences,
users,
webhooks,
workflows,
workspaces,
)
# ── Graceful shutdown signal ─────────────────────────────────────────────────
# Set during lifespan shutdown so middleware and handlers can stop accepting work.
_shutdown_event = asyncio.Event()
@@ -148,13 +145,15 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
)
# Report to Forgejo error reporter
try:
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
await report_error_to_forgejo({
"message": f"[Backend] {method} {path}: {exc}",
"stack": tb_str,
"url": str(request.url),
"context": {"method": method, "path": path, "source": "backend_middleware", "trace_id": trace_id},
})
from app.plugins.builtins.contracts import get_contract
reporter_contract = get_contract("forgejo_error_reporter")
if reporter_contract is not None:
await reporter_contract.report_error_to_forgejo({
"message": f"[Backend] {method} {path}: {exc}",
"stack": tb_str,
"url": str(request.url),
"context": {"method": method, "path": path, "source": "backend_middleware", "trace_id": trace_id},
})
except Exception:
pass # Never let error reporting break the request
raise
@@ -168,12 +167,14 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
# Report 4xx and 5xx errors to Forgejo (except 401/403 which are expected)
if status_code >= 400 and status_code not in (401, 403):
try:
from app.plugins.builtins.forgejo_error_reporter.service import report_error_to_forgejo
await report_error_to_forgejo({
"message": f"[Backend] {method} {path}{status_code}",
"url": str(request.url),
"context": {"method": method, "path": path, "status": status_code, "source": "backend_response", "trace_id": trace_id},
})
from app.plugins.builtins.contracts import get_contract
reporter_contract = get_contract("forgejo_error_reporter")
if reporter_contract is not None:
await reporter_contract.report_error_to_forgejo({
"message": f"[Backend] {method} {path}{status_code}",
"url": str(request.url),
"context": {"method": method, "path": path, "status": status_code, "source": "backend_response", "trace_id": trace_id},
})
except Exception:
pass # Never let error reporting break the response
@@ -197,8 +198,8 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown."""
# Initialize global Redis client (singleton)
from app.core.auth import init_redis, close_redis
from app.core.jobs import init_job_pool, close_job_pool
from app.core.auth import close_redis, init_redis
from app.core.jobs import close_job_pool, init_job_pool
await init_redis()
await init_job_pool()
@@ -216,15 +217,16 @@ async def lifespan(app: FastAPI):
# Install discovered builtin plugins and activate only those marked active in DB
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.models.plugin import Plugin as PluginModel
from app.core.event_bus import get_event_bus
from app.models.plugin import Plugin as PluginModel
event_bus = get_event_bus()
async_session = async_sessionmaker(get_engine(), expire_on_commit=False)
# Load all tenant IDs for per-tenant plugin activation (RLS fail-closed requires tenant context)
from app.models.tenant import Tenant as TenantModel
from app.core.db import set_tenant_context
from app.models.tenant import Tenant as TenantModel
async with async_session() as db:
tenant_result = await db.execute(sa_select(TenantModel.id))
@@ -333,15 +335,17 @@ async def lifespan(app: FastAPI):
register_trigger_dispatcher(event_bus)
logger.info("Trigger dispatcher registered")
# Register entity restore configurations (Phase D — Undo/Restore)
from app.core.restore_registry import register_default_entities
register_default_entities()
logger.info("Entity restore registry initialized")
# Entity restore + history hooks are registered by plugins in on_activate(),
# including Contacts (via ContactsPlugin). No Core special case here.
# Register hook-based history recording (Phase D — Undo/Restore)
from app.core.history_hooks import register_default_history_hooks
register_default_history_hooks()
logger.info("History hooks registered")
# Register entity models from active plugins (P0-3 fix)
from app.services.entity_permission_service import register_entity_model
for name in active_plugin_names:
plugin = registry.get_plugin(name)
if plugin:
for entity_type, model_class in plugin.get_entity_models().items():
register_entity_model(entity_type, model_class)
logger.info("Entity models registered for %d active plugins", len(active_plugin_names))
# Register field definitions from active plugins only
from app.core.permission_registry import get_permission_registry
@@ -356,8 +360,8 @@ async def lifespan(app: FastAPI):
# Seed default data (EUR currency, 19%/7% tax rates) for all tenants
# ⚠️ Use migration engine (crm_migration, BYPASSRLS) — RLS on currencies/taxes
# blocks inserts from crm_api role without tenant context.
from app.core.seeds import seed_default_data
from app.core.db import get_migration_session_factory
from app.core.seeds import seed_default_data
mig_session_factory = get_migration_session_factory()
async with mig_session_factory() as db:
@@ -386,7 +390,7 @@ async def lifespan(app: FastAPI):
# Give in-flight requests time to complete (max 30s)
try:
await asyncio.wait_for(_drain_inflight(), timeout=30.0)
except asyncio.TimeoutError:
except TimeoutError:
logger.warning("Graceful shutdown: 30s timeout reached, forcing shutdown")
# Close global Redis and ARQ pool
@@ -575,63 +579,41 @@ def create_app() -> FastAPI:
app.include_router(outbox.router)
app.include_router(api_tokens.router)
# ── Register plugin routes for all built-in plugins ──
# ── Register plugin routes for all discovered plugins ──
# Routes are registered at app creation time so OpenAPI docs are complete.
# Activation status is enforced per-request via require_active_plugin().
import importlib
# Plugin modules are discovered dynamically via the registry — no hardcoded list.
from app.deps import require_active_plugin
# Discover all built-in plugin modules and register their routes
plugin_modules = [
"app.plugins.builtins.tags",
"app.plugins.builtins.permissions",
"app.plugins.builtins.entity_links",
"app.plugins.builtins.dms",
"app.plugins.builtins.calendar",
"app.plugins.builtins.mail",
"app.plugins.builtins.report_generator",
"app.plugins.builtins.kommunikation",
"app.plugins.builtins.tasks",
"app.plugins.builtins.automation",
"app.plugins.builtins.ai_assistant",
"app.plugins.builtins.ai_proactive",
"app.plugins.builtins.ai_ui_control",
"app.plugins.builtins.mcp_client",
"app.plugins.builtins.mcp_server",
"app.plugins.builtins.system_notif",
"app.plugins.builtins.unified_search",
"app.plugins.builtins.forgejo_error_reporter",
"app.plugins.builtins.agent_memory",
"app.plugins.builtins.graph_rag",
"app.plugins.builtins.marketplace",
]
for mod_name in plugin_modules:
from app.plugins.registry import get_registry
_route_registry = get_registry()
# Ensure builtins are discovered before registering routes.
# discover_builtins() is idempotent — safe to call even if lifespan hasn't run yet.
if not _route_registry.list_discovered():
_route_registry.discover_builtins()
for plugin_name in _route_registry.list_discovered():
plugin = _route_registry.get_plugin(plugin_name)
if plugin is None or not plugin.manifest.routes:
continue
try:
mod = importlib.import_module(mod_name)
# Find the plugin class and get its manifest routes
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)
router = getattr(router_module, route_def.router_attr)
# Check if this route definition is public (no auth required)
is_public = getattr(route_def, "is_public", False)
if is_public:
# Public routes: no auth dependency, no plugin check
app.include_router(router)
logger.info(f"Registered PUBLIC routes for {plugin_name}: {route_def.module}")
continue
plugin_dep = Depends(require_active_plugin(plugin_name))
# Use include_router with dependencies to avoid mutating
# the shared module-level router object (which tests reuse)
app.include_router(router, dependencies=[plugin_dep])
except Exception as exc:
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
break
for route_def in plugin.manifest.routes:
try:
router_module = importlib.import_module(route_def.module)
router = getattr(router_module, route_def.router_attr)
# Check if this route definition is public (no auth required)
is_public = getattr(route_def, "is_public", False)
if is_public:
# Public routes: no auth dependency, no plugin check
app.include_router(router)
logger.info(f"Registered PUBLIC routes for {plugin_name}: {route_def.module}")
continue
plugin_dep = Depends(require_active_plugin(plugin_name))
# Use include_router with dependencies to avoid mutating
# the shared module-level router object (which tests reuse)
app.include_router(router, dependencies=[plugin_dep])
except Exception as exc:
logger.error(f"Failed to register route {route_def.module}.{route_def.router_attr}: {exc}")
except Exception as exc:
logger.error(f"Failed to register plugin routes for {mod_name}: {exc}")
logger.error(f"Failed to register plugin routes for {plugin_name}: {exc}")
# ── Serve frontend static files (SPA) ──────────────────────────────
# Mount built frontend assets (JS, CSS, images)
@@ -654,7 +636,7 @@ def create_app() -> FastAPI:
if full_path.startswith(blocked_prefixes) or ".." in full_path:
raise HTTPException(status_code=404, detail="Not Found")
index_path = os.path.join(frontend_dist, "index.html")
if os.path.isfile(index_path):
if os.path.isfile(index_path): # noqa: ASYNC240
return FileResponse(index_path)
raise HTTPException(status_code=404, detail="Frontend not built")