fix(security): F05 (Astra P1) — Plugin-Gate laeuft NACH Authentisierung, fail-closed

Vorher: require_active_plugin hing nicht an einer Auth-Dependency —
FastAPI konnte die Plugin-Pruefung VOR der Authentisierung ausfuehren.
Der Mandant wurde aus dem DB-Kontext gelesen (current_setting), der zu
diesem Zeitpunkt oft fehlt → stiller Return = Plugin aktiv. Der Code
trug sogar ein TODO: Fix in production. Astra-Repro: Endpunkt
antwortete HTTP 200 ohne Mandantenkontext.

Fix:
- _check haengt an get_current_user_or_bearer (Cookie- UND Bearer-Auth)
  → FastAPI aufloesungsbedingt immer authentifiziert vor dem Gate
- Mandant kommt aus dem authentifizierten User-Kontext, nie aus
  current_setting
- Fehlender Mandanten-Kontext → 403 plugin_gate_no_tenant (fail-closed,
  war: stiller Durchlass)
- Public-Routen (is_public) umgehen das Gate weiterhin korrekt

Nebenwirkung positiv: Bearer-Clients (External-API, MCP) laufen nicht
mehr gegen den Cookie-Zwang des Gates.

Verifikation: Syntax OK, ruff clean, test_s1_security_guards +
test_auth 21/21. Der Gate-Order-Beweis ist ein
Integrationstest-Verhalten (HTTP) — Plugin-Inactive-Faelle werden
bereits durch die permission_system_live-Suite abgedeckt.
This commit is contained in:
Agent Zero
2026-09-18 08:04:41 +02:00
parent 632554bf28
commit a17772ade5
+23 -13
View File
@@ -438,7 +438,16 @@ def require_active_plugin(plugin_name: str):
"""
async def _check(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user_or_bearer),
) -> None:
"""F05 (Astra P1): the plugin gate runs AFTER authentication.
Depending on ``get_current_user_or_bearer`` guarantees FastAPI
resolves the authenticated user context BEFORE this check — the
previous version read the tenant from the DB session before auth
had run (context missing → silent allow). Both auth paths (cookie
and Bearer) set the tenant context on this same ``db`` session.
"""
from app.core.permission_registry import get_permission_registry
try:
registry = get_permission_registry()
@@ -450,19 +459,20 @@ def require_active_plugin(plugin_name: str):
"code": "plugin_inactive",
},
)
# Get tenant_id from existing db session (NOT a new session)
# The tenant context is set by middleware/get_current_user on this same session
from sqlalchemy import text as sa_text
result = await db.execute(
sa_text("SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::uuid")
)
tenant_id = result.scalar()
if tenant_id is None:
# No tenant context — plugin is active by default (backward compatible)
# TODO: Fix in production to deny access when no tenant context
return
# Tenant comes from the AUTHENTICATED user context — never from
# the DB session (which may not have the context set yet).
raw_tid = current_user.get("tenant_id")
if not raw_tid:
# Fail-closed: no authenticated tenant context → reject.
# (Previously this returned silently = plugin active.)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": "Plugin gate requires an authenticated tenant context",
"code": "plugin_gate_no_tenant",
},
)
tenant_id = uuid.UUID(str(raw_tid))
# Per-tenant activation check with Redis cache
import json