Files
vulncheck/app/routers/auth_admin.py
T
vulncheckandClaude Opus 5 8362369ad3 perf(api): die Requests liefen alle auf dem Event-Loop, also nacheinander
Ein Dashboard-Aufruf feuert acht Requests. Im Log kamen alle in derselben
Millisekunde zurueck, /auth/me mit 1116ms — ein Request, der eine Zeile liest.
Nichts davon war fuer sich langsam: einzeln gemessen kosten die acht zusammen
355ms, die Seite brauchte trotzdem so lange wie ihre Summe statt so lange wie
ihr teuerster Request.

Die Handler waren `async def`, obwohl jede Zeile darin eine blockierende
SQLAlchemy-Session benutzt. FastAPI fuehrt `async def` auf dem Event-Loop
selbst aus, also hielt jeder Handler den einzigen Loop fuer die Dauer seines
kompletten Query-Batches fest — und `get_current_user` haengt vor jedem
authentifizierten Request, deshalb war auch der Ein-Zeilen-Request betroffen.
111 solcher Handler, keiner davon mit einem einzigen `await`, sind jetzt
plain `def` und laufen im Worker-Threadpool, wo Blockieren vorgesehen ist.

Damit sie dort auch eine Verbindung finden, deckt der Connection-Pool jetzt
die 40 Threads ab, die FastAPI vergibt, statt 30: die 31. gleichzeitige
Anfrage haette nicht auf eine langsame Query gewartet, sondern auf eine
Connection, und das ist ein Timeout, keine Langsamkeit.

Zweiter Posten auf derselben Seite: kev-recent baute den zusammengefuehrten
KEV-Katalog bei jedem Request neu — 2 MB JSON aus `settings` parsen und
mergen, zweimal pro Seitenaufruf, fuer einen Katalog der sich einmal am Tag
aendert. Jetzt memoisiert, und zwar auf den `_updated_at`-Werten der
Quell-Caches statt auf einer Uhr, damit "jetzt aktualisieren, Seite neu laden"
weiter den neuen Katalog zeigt. Das Datumsparsen laeuft ueber fromisoformat
statt strptime; mit 9100 Aufrufen pro Request war es der Hotspot im Merge.

Gemessen mit acht gleichzeitigen Requests gegen 36.000 Findings auf 300
Assets: Seitenaufruf 420ms -> 190ms, Event-Loop blockiert 250ms -> 50ms,
/auth/me unter Last 304ms -> 91ms. Assets-/Scans-Seite 146ms -> 108ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:53:23 +02:00

348 lines
12 KiB
Python

"""
Admin-only routes for managing the auth-provider stack.
Provider _credentials_ (LDAP host, OIDC client id, SAML certs, ...) stay
in environment variables — they require process restart to take effect
and shouldn't be hot-edited via API. What IS editable at runtime is the
group→role mapping, because admins re-evaluate org structure often.
Endpoints:
- GET /api/v1/auth-config/status provider enablement + last sync stats
- GET /api/v1/auth-config/role-mappings current mapping rules
- PUT /api/v1/auth-config/role-mappings replace mapping rules (validated)
- POST /api/v1/auth-config/ldap/test test service-account bind + sample search
- POST /api/v1/auth-config/oidc/test fetch discovery doc, check signing keys
- POST /api/v1/auth-config/saml/test fetch IdP metadata, return entity ID
"""
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from typing import Dict, List, Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.auth.dependencies import RequireAdmin
from app.auth.role_mapper import SETTINGS_KEY as ROLE_MAP_SETTINGS_KEY
from app.database import get_db
from app.models.audit_log import AuditEventType, AuditLog
from app.models.setting import Setting
from app.models.user import AuthProvider, User, UserRole
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/auth-config", tags=["Auth Config"])
# Whitelist for role-mapping JSON sanitisation
VALID_PROVIDERS = {p.value for p in AuthProvider}
VALID_ROLES = {r.value for r in UserRole}
# ============================================
# Schemas
# ============================================
class RoleMappingRule(BaseModel):
pattern: str = Field(..., min_length=1, max_length=512)
role: str
@field_validator("role")
@classmethod
def role_must_be_valid(cls, v: str) -> str:
v = v.lower().strip()
if v not in VALID_ROLES:
raise ValueError(f"role must be one of {sorted(VALID_ROLES)}")
return v
class RoleMappingsPayload(BaseModel):
"""{provider: [rule, rule, ...], ...}"""
mappings: Dict[str, List[RoleMappingRule]]
@field_validator("mappings")
@classmethod
def keys_must_be_known_providers(cls, v):
bad = [k for k in v.keys() if k.lower() not in VALID_PROVIDERS]
if bad:
raise ValueError(f"unknown providers: {bad}")
return {k.lower(): rules for k, rules in v.items()}
class LdapTestRequest(BaseModel):
sample_username: Optional[str] = Field(
None,
description="Optional: search this username after a successful service bind to verify filter + attrs.",
)
# ============================================
# Helpers
# ============================================
def _env_list(name: str, default: str) -> List[str]:
return [x.strip().lower() for x in os.getenv(name, default).split(",") if x.strip()]
def _audit(db: Session, user_id: int, event: AuditEventType, desc: str, request: Optional[Request] = None):
db.add(
AuditLog(
user_id=user_id,
event_type=event,
event_description=desc[:500],
ip_address=request.client.host if (request and request.client) else None,
user_agent=request.headers.get("user-agent") if request else None,
timestamp=datetime.now(),
)
)
db.commit()
# ============================================
# Status
# ============================================
@router.get("/status")
def auth_status(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
"""
Provider-enablement + readiness check. Read-only. Reflects what is
configured via .env (provider config stays env-controlled).
"""
enabled = _env_list("AUTH_PROVIDERS", "local")
lookup_order = _env_list("AUTH_LOOKUP_ORDER", "local,ldap")
jit = os.getenv("AUTH_JIT_PROVISIONING", "true").lower() == "true"
default_role = os.getenv("AUTH_JIT_DEFAULT_ROLE", "readonly")
# User counts per provider (operational signal)
user_counts: dict[str, int] = {}
for p in AuthProvider:
user_counts[p.value] = (
db.query(User).filter(User.auth_provider == p).count()
)
return {
"providers": [
{
"name": p,
"enabled": p in enabled,
"configured": _provider_is_configured(p),
"user_count": user_counts.get(p, 0),
}
for p in [pp.value for pp in AuthProvider]
],
"lookup_order": lookup_order,
"jit_provisioning": jit,
"default_role": default_role,
"crypto_key_set": bool(os.getenv("AUTH_PROVIDER_CRYPTO_KEY")),
}
def _provider_is_configured(provider_name: str) -> bool:
"""Heuristic: minimum env vars to consider the provider 'ready'."""
if provider_name == "local":
return True
if provider_name == "ldap":
return bool(os.getenv("LDAP_HOST") and os.getenv("LDAP_BIND_DN") and os.getenv("LDAP_USER_SEARCH_BASE"))
if provider_name == "oidc":
return bool(
os.getenv("OIDC_DISCOVERY_URL")
and os.getenv("OIDC_CLIENT_ID")
and os.getenv("OIDC_CLIENT_SECRET")
and os.getenv("OIDC_REDIRECT_URI")
)
if provider_name == "saml":
return bool(
(os.getenv("SAML_IDP_METADATA_URL") or os.getenv("SAML_IDP_METADATA_PATH"))
and os.getenv("SAML_SP_ENTITY_ID")
and os.getenv("SAML_SP_ACS_URL")
and os.getenv("SAML_SP_CERT_PATH")
and os.getenv("SAML_SP_PRIVATE_KEY_PATH")
)
return False
# ============================================
# Role mappings
# ============================================
@router.get("/role-mappings")
def get_role_mappings(
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
row = db.query(Setting).filter(Setting.key == ROLE_MAP_SETTINGS_KEY).first()
if not row or not row.value:
return {"mappings": {p.value: [] for p in AuthProvider if p != AuthProvider.LOCAL}}
try:
data = json.loads(row.value)
except json.JSONDecodeError:
return {"mappings": {}}
return {"mappings": data}
@router.put("/role-mappings")
def put_role_mappings(
payload: RoleMappingsPayload,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
# Serialise back to JSON-storable dict
out = {
prov: [rule.model_dump() for rule in rules]
for prov, rules in payload.mappings.items()
}
raw = json.dumps(out)
row = db.query(Setting).filter(Setting.key == ROLE_MAP_SETTINGS_KEY).first()
if row:
row.value = raw
else:
row = Setting(
key=ROLE_MAP_SETTINGS_KEY,
value=raw,
description="External group → internal role mapping rules per provider",
)
db.add(row)
db.commit()
_audit(
db,
current_user.id,
AuditEventType.CONFIG_CHANGE,
f"auth_role_mappings updated by {current_user.username}: "
+ ", ".join(f"{p}={len(r)}" for p, r in out.items()),
request,
)
return {"mappings": out}
# ============================================
# Provider test endpoints
# ============================================
@router.post("/ldap/test")
def test_ldap(
payload: LdapTestRequest,
db: Session = Depends(get_db),
current_user: User = Depends(RequireAdmin),
):
"""Verify LDAP config: open server, bind as service account, optionally search a user."""
if not _provider_is_configured("ldap"):
raise HTTPException(400, "LDAP is not configured (check LDAP_* env vars)")
# Import inside endpoint so the dependency only loads when actually used.
try:
from app.auth.strategies.ldap_strategy import LdapStrategy
except ImportError as e:
raise HTTPException(500, f"ldap3 not available: {e}")
strategy = LdapStrategy(db)
try:
conn = strategy._service_connection() # type: ignore[attr-defined]
except Exception as e:
logger.warning("LDAP test bind failed: %s", e)
return {"ok": False, "step": "service_bind", "error": str(e)}
out = {
"ok": True,
"step": "service_bind",
"server": f"{strategy.host}:{strategy.port}",
"use_ssl": strategy.use_ssl,
"use_starttls": strategy.use_starttls,
}
try:
if payload.sample_username:
user_dn, attrs = strategy._search_user(payload.sample_username) # type: ignore[attr-defined]
out["sample"] = {
"dn": user_dn,
"username_attr_value": str(attrs.get(strategy.attr_username, "<missing>")),
"email_attr_value": str(attrs.get(strategy.attr_email, "<missing>")),
"groups_count": len(attrs.get(strategy.attr_groups, []) or []),
}
except Exception as e:
out["sample_error"] = str(e)
finally:
try:
conn.unbind()
except Exception:
pass
return out
@router.post("/oidc/test")
def test_oidc(
current_user: User = Depends(RequireAdmin),
):
"""Fetch OIDC discovery doc + JWKS, sanity-check fields."""
if not _provider_is_configured("oidc"):
raise HTTPException(400, "OIDC is not configured (check OIDC_* env vars)")
discovery_url = os.getenv("OIDC_DISCOVERY_URL", "")
try:
with httpx.Client(timeout=15) as client:
r = client.get(discovery_url)
r.raise_for_status()
doc = r.json()
jwks_uri = doc.get("jwks_uri")
jwks = None
if jwks_uri:
jwks_r = client.get(jwks_uri)
if jwks_r.status_code == 200:
jwks = jwks_r.json()
except httpx.HTTPError as e:
return {"ok": False, "error": str(e)}
return {
"ok": True,
"issuer": doc.get("issuer"),
"authorization_endpoint": doc.get("authorization_endpoint"),
"token_endpoint": doc.get("token_endpoint"),
"userinfo_endpoint": doc.get("userinfo_endpoint"),
"jwks_uri": doc.get("jwks_uri"),
"supported_scopes": doc.get("scopes_supported"),
"supported_id_token_signing_algs": doc.get("id_token_signing_alg_values_supported"),
"jwks_key_count": len(jwks.get("keys", [])) if jwks else 0,
}
@router.post("/saml/test")
def test_saml(
current_user: User = Depends(RequireAdmin),
):
"""Fetch IdP metadata, return SSO URL + entity ID + cert thumbprint hint."""
if not _provider_is_configured("saml"):
raise HTTPException(400, "SAML is not configured")
try:
from onelogin.saml2.idp_metadata_parser import OneLogin_Saml2_IdPMetadataParser
except ImportError as e:
raise HTTPException(500, f"python3-saml not available: {e}")
url = os.getenv("SAML_IDP_METADATA_URL", "")
path = os.getenv("SAML_IDP_METADATA_PATH", "")
try:
if url:
data = OneLogin_Saml2_IdPMetadataParser.parse_remote(url, timeout=15)
else:
with open(path, "r") as f:
data = OneLogin_Saml2_IdPMetadataParser.parse(f.read())
except Exception as e:
return {"ok": False, "error": str(e)}
idp = data.get("idp", {})
return {
"ok": True,
"idp_entity_id": idp.get("entityId"),
"sso_url": (idp.get("singleSignOnService") or {}).get("url"),
"slo_url": (idp.get("singleLogoutService") or {}).get("url"),
"x509cert_present": bool(idp.get("x509cert")),
}