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:
+22
-12
@@ -438,7 +438,16 @@ def require_active_plugin(plugin_name: str):
|
|||||||
"""
|
"""
|
||||||
async def _check(
|
async def _check(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: dict[str, Any] = Depends(get_current_user_or_bearer),
|
||||||
) -> None:
|
) -> 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
|
from app.core.permission_registry import get_permission_registry
|
||||||
try:
|
try:
|
||||||
registry = get_permission_registry()
|
registry = get_permission_registry()
|
||||||
@@ -450,19 +459,20 @@ def require_active_plugin(plugin_name: str):
|
|||||||
"code": "plugin_inactive",
|
"code": "plugin_inactive",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Get tenant_id from existing db session (NOT a new session)
|
# Tenant comes from the AUTHENTICATED user context — never from
|
||||||
# The tenant context is set by middleware/get_current_user on this same session
|
# the DB session (which may not have the context set yet).
|
||||||
from sqlalchemy import text as sa_text
|
raw_tid = current_user.get("tenant_id")
|
||||||
|
if not raw_tid:
|
||||||
result = await db.execute(
|
# Fail-closed: no authenticated tenant context → reject.
|
||||||
sa_text("SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::uuid")
|
# (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 = result.scalar()
|
tenant_id = uuid.UUID(str(raw_tid))
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Per-tenant activation check with Redis cache
|
# Per-tenant activation check with Redis cache
|
||||||
import json
|
import json
|
||||||
|
|||||||
Reference in New Issue
Block a user