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:
+104
-122
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user