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>
348 lines
12 KiB
Python
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")),
|
|
}
|