[split-required] Split final 43 files (500-668 LOC) to complete refactoring

klausur-service (11 files):
- cv_gutter_repair, ocr_pipeline_regression, upload_api
- ocr_pipeline_sessions, smart_spell, nru_worksheet_generator
- ocr_pipeline_overlays, mail/aggregator, zeugnis_api
- cv_syllable_detect, self_rag

backend-lehrer (17 files):
- classroom_engine/suggestions, generators/quiz_generator
- worksheets_api, llm_gateway/comparison, state_engine_api
- classroom/models (→ 4 submodules), services/file_processor
- alerts_agent/api/wizard+digests+routes, content_generators/pdf
- classroom/routes/sessions, llm_gateway/inference
- classroom_engine/analytics, auth/keycloak_auth
- alerts_agent/processing/rule_engine, ai_processor/print_versions

agent-core (5 files):
- brain/memory_store, brain/knowledge_graph, brain/context_manager
- orchestrator/supervisor, sessions/session_manager

admin-lehrer (5 components):
- GridOverlay, StepGridReview, DevOpsPipelineSidebar
- DataFlowDiagram, sbom/wizard/page

website (2 files):
- DependencyMap, lehrer/abitur-archiv

Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 09:41:42 +02:00
parent 451365a312
commit bd4b956e3c
113 changed files with 13790 additions and 14148 deletions

View File

@@ -4,15 +4,11 @@ BreakPilot Authentication Module
Hybrid authentication supporting both Keycloak and local JWT tokens.
"""
from .keycloak_auth import (
from .keycloak_models import (
# Config
KeycloakConfig,
KeycloakUser,
# Authenticators
KeycloakAuthenticator,
HybridAuthenticator,
# Exceptions
KeycloakAuthError,
TokenExpiredError,
@@ -21,6 +17,14 @@ from .keycloak_auth import (
# Factory functions
get_keycloak_config_from_env,
)
from .keycloak_auth import (
# Authenticators
KeycloakAuthenticator,
HybridAuthenticator,
# Factory functions
get_authenticator,
get_auth,

View File

@@ -14,110 +14,24 @@ import os
import httpx
import jwt
from jwt import PyJWKClient
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
from functools import lru_cache
import logging
from typing import Optional, Dict, Any
from .keycloak_models import (
KeycloakConfig,
KeycloakUser,
KeycloakAuthError,
TokenExpiredError,
TokenInvalidError,
KeycloakConfigError,
get_keycloak_config_from_env,
)
logger = logging.getLogger(__name__)
@dataclass
class KeycloakConfig:
"""Keycloak connection configuration."""
server_url: str
realm: str
client_id: str
client_secret: Optional[str] = None
verify_ssl: bool = True
@property
def issuer_url(self) -> str:
return f"{self.server_url}/realms/{self.realm}"
@property
def jwks_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/certs"
@property
def token_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/token"
@property
def userinfo_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/userinfo"
@dataclass
class KeycloakUser:
"""User information extracted from Keycloak token."""
user_id: str # Keycloak subject (sub)
email: str
email_verified: bool
name: Optional[str]
given_name: Optional[str]
family_name: Optional[str]
realm_roles: List[str] # Keycloak realm roles
client_roles: Dict[str, List[str]] # Client-specific roles
groups: List[str] # Keycloak groups
tenant_id: Optional[str] # Custom claim for school/tenant
raw_claims: Dict[str, Any] # All claims for debugging
def has_realm_role(self, role: str) -> bool:
"""Check if user has a specific realm role."""
return role in self.realm_roles
def has_client_role(self, client_id: str, role: str) -> bool:
"""Check if user has a specific client role."""
client_roles = self.client_roles.get(client_id, [])
return role in client_roles
def is_admin(self) -> bool:
"""Check if user has admin role."""
return self.has_realm_role("admin") or self.has_realm_role("schul_admin")
def is_teacher(self) -> bool:
"""Check if user is a teacher."""
return self.has_realm_role("teacher") or self.has_realm_role("lehrer")
class KeycloakAuthError(Exception):
"""Base exception for Keycloak authentication errors."""
pass
class TokenExpiredError(KeycloakAuthError):
"""Token has expired."""
pass
class TokenInvalidError(KeycloakAuthError):
"""Token is invalid."""
pass
class KeycloakConfigError(KeycloakAuthError):
"""Keycloak configuration error."""
pass
class KeycloakAuthenticator:
"""
Validates JWT tokens against Keycloak.
Usage:
config = KeycloakConfig(
server_url="https://keycloak.example.com",
realm="breakpilot",
client_id="breakpilot-backend"
)
auth = KeycloakAuthenticator(config)
user = await auth.validate_token(token)
if user.is_teacher():
# Grant access
"""
"""Validates JWT tokens against Keycloak."""
def __init__(self, config: KeycloakConfig):
self.config = config
@@ -126,64 +40,29 @@ class KeycloakAuthenticator:
@property
def jwks_client(self) -> PyJWKClient:
"""Lazy-load JWKS client."""
if self._jwks_client is None:
self._jwks_client = PyJWKClient(
self.config.jwks_url,
cache_keys=True,
lifespan=3600 # Cache keys for 1 hour
)
self._jwks_client = PyJWKClient(self.config.jwks_url, cache_keys=True, lifespan=3600)
return self._jwks_client
async def get_http_client(self) -> httpx.AsyncClient:
"""Get or create async HTTP client."""
if self._http_client is None or self._http_client.is_closed:
self._http_client = httpx.AsyncClient(
verify=self.config.verify_ssl,
timeout=30.0
)
self._http_client = httpx.AsyncClient(verify=self.config.verify_ssl, timeout=30.0)
return self._http_client
async def close(self):
"""Close HTTP client."""
if self._http_client and not self._http_client.is_closed:
await self._http_client.aclose()
def validate_token_sync(self, token: str) -> KeycloakUser:
"""
Synchronously validate a JWT token against Keycloak JWKS.
Args:
token: The JWT access token
Returns:
KeycloakUser with extracted claims
Raises:
TokenExpiredError: If token has expired
TokenInvalidError: If token signature is invalid
"""
"""Synchronously validate a JWT token against Keycloak JWKS."""
try:
# Get signing key from JWKS
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
# Decode and validate token
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=self.config.client_id,
issuer=self.config.issuer_url,
options={
"verify_exp": True,
"verify_iat": True,
"verify_aud": True,
"verify_iss": True
}
token, signing_key.key, algorithms=["RS256"],
audience=self.config.client_id, issuer=self.config.issuer_url,
options={"verify_exp": True, "verify_iat": True, "verify_aud": True, "verify_iss": True}
)
return self._extract_user(payload)
except jwt.ExpiredSignatureError:
raise TokenExpiredError("Token has expired")
except jwt.InvalidAudienceError:
@@ -197,27 +76,14 @@ class KeycloakAuthenticator:
raise TokenInvalidError(f"Token validation failed: {e}")
async def validate_token(self, token: str) -> KeycloakUser:
"""
Asynchronously validate a JWT token.
Note: JWKS fetching is synchronous due to PyJWKClient limitations,
but this wrapper allows async context usage.
"""
"""Asynchronously validate a JWT token."""
return self.validate_token_sync(token)
async def get_userinfo(self, token: str) -> Dict[str, Any]:
"""
Fetch user info from Keycloak userinfo endpoint.
This provides additional user claims not in the access token.
"""
"""Fetch user info from Keycloak userinfo endpoint."""
client = await self.get_http_client()
try:
response = await client.get(
self.config.userinfo_url,
headers={"Authorization": f"Bearer {token}"}
)
response = await client.get(self.config.userinfo_url, headers={"Authorization": f"Bearer {token}"})
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
@@ -227,94 +93,51 @@ class KeycloakAuthenticator:
def _extract_user(self, payload: Dict[str, Any]) -> KeycloakUser:
"""Extract KeycloakUser from JWT payload."""
# Extract realm roles
realm_access = payload.get("realm_access", {})
realm_roles = realm_access.get("roles", [])
# Extract client roles
resource_access = payload.get("resource_access", {})
client_roles = {}
for client_id, access in resource_access.items():
client_roles[client_id] = access.get("roles", [])
# Extract groups
groups = payload.get("groups", [])
# Extract custom tenant claim (if configured in Keycloak)
tenant_id = payload.get("tenant_id") or payload.get("school_id")
return KeycloakUser(
user_id=payload.get("sub", ""),
email=payload.get("email", ""),
user_id=payload.get("sub", ""), email=payload.get("email", ""),
email_verified=payload.get("email_verified", False),
name=payload.get("name"),
given_name=payload.get("given_name"),
name=payload.get("name"), given_name=payload.get("given_name"),
family_name=payload.get("family_name"),
realm_roles=realm_roles,
client_roles=client_roles,
groups=groups,
tenant_id=tenant_id,
raw_claims=payload
realm_roles=realm_roles, client_roles=client_roles,
groups=groups, tenant_id=tenant_id, raw_claims=payload
)
# =============================================
# HYBRID AUTH: Keycloak + Local JWT
# =============================================
class HybridAuthenticator:
"""
Hybrid authenticator supporting both Keycloak and local JWT tokens.
"""Hybrid authenticator supporting both Keycloak and local JWT tokens."""
This allows gradual migration from local JWT to Keycloak:
1. Development: Use local JWT (fast, no external dependencies)
2. Production: Use Keycloak for full IAM capabilities
Token type detection:
- Keycloak tokens: Have 'iss' claim matching Keycloak URL
- Local tokens: Have 'iss' claim as 'breakpilot' or no 'iss'
"""
def __init__(
self,
keycloak_config: Optional[KeycloakConfig] = None,
local_jwt_secret: Optional[str] = None,
environment: str = "development"
):
def __init__(self, keycloak_config=None, local_jwt_secret=None, environment="development"):
self.environment = environment
self.keycloak_enabled = keycloak_config is not None
self.local_jwt_secret = local_jwt_secret
if keycloak_config:
self.keycloak_auth = KeycloakAuthenticator(keycloak_config)
else:
self.keycloak_auth = None
self.keycloak_auth = KeycloakAuthenticator(keycloak_config) if keycloak_config else None
async def validate_token(self, token: str) -> Dict[str, Any]:
"""
Validate token using appropriate method.
Returns a unified user dict compatible with existing code.
"""
"""Validate token using appropriate method."""
if not token:
raise TokenInvalidError("No token provided")
# Try to peek at the token to determine type
try:
# Decode without verification to check issuer
unverified = jwt.decode(token, options={"verify_signature": False})
issuer = unverified.get("iss", "")
except jwt.InvalidTokenError:
raise TokenInvalidError("Cannot decode token")
# Check if it's a Keycloak token
if self.keycloak_auth and self.keycloak_auth.config.issuer_url in issuer:
# Validate with Keycloak
kc_user = await self.keycloak_auth.validate_token(token)
return self._keycloak_user_to_dict(kc_user)
# Fall back to local JWT validation
if self.local_jwt_secret:
return self._validate_local_token(token)
@@ -326,13 +149,7 @@ class HybridAuthenticator:
raise KeycloakConfigError("Local JWT secret not configured")
try:
payload = jwt.decode(
token,
self.local_jwt_secret,
algorithms=["HS256"]
)
# Map local token claims to unified format
payload = jwt.decode(token, self.local_jwt_secret, algorithms=["HS256"])
return {
"user_id": payload.get("user_id", payload.get("sub", "")),
"email": payload.get("email", ""),
@@ -349,7 +166,6 @@ class HybridAuthenticator:
def _keycloak_user_to_dict(self, user: KeycloakUser) -> Dict[str, Any]:
"""Convert KeycloakUser to dict compatible with existing code."""
# Map Keycloak roles to our role system
role = "user"
if user.is_admin():
role = "admin"
@@ -357,20 +173,15 @@ class HybridAuthenticator:
role = "teacher"
return {
"user_id": user.user_id,
"email": user.email,
"user_id": user.user_id, "email": user.email,
"name": user.name or f"{user.given_name or ''} {user.family_name or ''}".strip(),
"role": role,
"realm_roles": user.realm_roles,
"client_roles": user.client_roles,
"groups": user.groups,
"tenant_id": user.tenant_id,
"email_verified": user.email_verified,
"role": role, "realm_roles": user.realm_roles,
"client_roles": user.client_roles, "groups": user.groups,
"tenant_id": user.tenant_id, "email_verified": user.email_verified,
"auth_method": "keycloak"
}
async def close(self):
"""Cleanup resources."""
if self.keycloak_auth:
await self.keycloak_auth.close()
@@ -379,57 +190,17 @@ class HybridAuthenticator:
# FACTORY FUNCTIONS
# =============================================
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
"""
Create KeycloakConfig from environment variables.
Required env vars:
- KEYCLOAK_SERVER_URL: e.g., https://keycloak.breakpilot.app
- KEYCLOAK_REALM: e.g., breakpilot
- KEYCLOAK_CLIENT_ID: e.g., breakpilot-backend
Optional:
- KEYCLOAK_CLIENT_SECRET: For confidential clients
- KEYCLOAK_VERIFY_SSL: Default true
"""
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
realm = os.environ.get("KEYCLOAK_REALM")
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
if not all([server_url, realm, client_id]):
logger.info("Keycloak not configured, using local JWT only")
return None
return KeycloakConfig(
server_url=server_url,
realm=realm,
client_id=client_id,
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
)
def get_authenticator() -> HybridAuthenticator:
"""
Get configured authenticator instance.
Uses environment variables to determine configuration.
"""
"""Get configured authenticator instance."""
keycloak_config = get_keycloak_config_from_env()
# JWT_SECRET is required - no default fallback in production
jwt_secret = os.environ.get("JWT_SECRET")
environment = os.environ.get("ENVIRONMENT", "development")
if not jwt_secret and environment == "production":
raise KeycloakConfigError(
"JWT_SECRET environment variable is required in production"
)
raise KeycloakConfigError("JWT_SECRET environment variable is required in production")
return HybridAuthenticator(
keycloak_config=keycloak_config,
local_jwt_secret=jwt_secret,
environment=environment
keycloak_config=keycloak_config, local_jwt_secret=jwt_secret, environment=environment
)
@@ -439,7 +210,6 @@ def get_authenticator() -> HybridAuthenticator:
from fastapi import Request, HTTPException, Depends
# Global authenticator instance (lazy-initialized)
_authenticator: Optional[HybridAuthenticator] = None
@@ -452,26 +222,16 @@ def get_auth() -> HybridAuthenticator:
async def get_current_user(request: Request) -> Dict[str, Any]:
"""
FastAPI dependency to get current authenticated user.
Usage:
@app.get("/api/protected")
async def protected_endpoint(user: dict = Depends(get_current_user)):
return {"user_id": user["user_id"]}
"""
"""FastAPI dependency to get current authenticated user."""
auth_header = request.headers.get("authorization", "")
if not auth_header.startswith("Bearer "):
# Check for development mode
environment = os.environ.get("ENVIRONMENT", "development")
if environment == "development":
# Return demo user in development without token
return {
"user_id": "10000000-0000-0000-0000-000000000024",
"email": "demo@breakpilot.app",
"role": "admin",
"realm_roles": ["admin"],
"role": "admin", "realm_roles": ["admin"],
"tenant_id": "a0000000-0000-0000-0000-000000000001",
"auth_method": "development_bypass"
}
@@ -492,24 +252,11 @@ async def get_current_user(request: Request) -> Dict[str, Any]:
async def require_role(required_role: str):
"""
FastAPI dependency factory for role-based access.
Usage:
@app.get("/api/admin-only")
async def admin_endpoint(user: dict = Depends(require_role("admin"))):
return {"message": "Admin access granted"}
"""
"""FastAPI dependency factory for role-based access."""
async def role_checker(user: dict = Depends(get_current_user)) -> dict:
user_role = user.get("role", "user")
realm_roles = user.get("realm_roles", [])
if user_role == required_role or required_role in realm_roles:
return user
raise HTTPException(
status_code=403,
detail=f"Role '{required_role}' required"
)
raise HTTPException(status_code=403, detail=f"Role '{required_role}' required")
return role_checker

View File

@@ -0,0 +1,104 @@
"""
Keycloak Authentication - Models, Config, and Exceptions.
"""
import os
import logging
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class KeycloakConfig:
"""Keycloak connection configuration."""
server_url: str
realm: str
client_id: str
client_secret: Optional[str] = None
verify_ssl: bool = True
@property
def issuer_url(self) -> str:
return f"{self.server_url}/realms/{self.realm}"
@property
def jwks_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/certs"
@property
def token_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/token"
@property
def userinfo_url(self) -> str:
return f"{self.issuer_url}/protocol/openid-connect/userinfo"
@dataclass
class KeycloakUser:
"""User information extracted from Keycloak token."""
user_id: str
email: str
email_verified: bool
name: Optional[str]
given_name: Optional[str]
family_name: Optional[str]
realm_roles: List[str]
client_roles: Dict[str, List[str]]
groups: List[str]
tenant_id: Optional[str]
raw_claims: Dict[str, Any]
def has_realm_role(self, role: str) -> bool:
return role in self.realm_roles
def has_client_role(self, client_id: str, role: str) -> bool:
client_roles = self.client_roles.get(client_id, [])
return role in client_roles
def is_admin(self) -> bool:
return self.has_realm_role("admin") or self.has_realm_role("schul_admin")
def is_teacher(self) -> bool:
return self.has_realm_role("teacher") or self.has_realm_role("lehrer")
class KeycloakAuthError(Exception):
"""Base exception for Keycloak authentication errors."""
pass
class TokenExpiredError(KeycloakAuthError):
"""Token has expired."""
pass
class TokenInvalidError(KeycloakAuthError):
"""Token is invalid."""
pass
class KeycloakConfigError(KeycloakAuthError):
"""Keycloak configuration error."""
pass
def get_keycloak_config_from_env() -> Optional[KeycloakConfig]:
"""Create KeycloakConfig from environment variables."""
server_url = os.environ.get("KEYCLOAK_SERVER_URL")
realm = os.environ.get("KEYCLOAK_REALM")
client_id = os.environ.get("KEYCLOAK_CLIENT_ID")
if not all([server_url, realm, client_id]):
logger.info("Keycloak not configured, using local JWT only")
return None
return KeycloakConfig(
server_url=server_url,
realm=realm,
client_id=client_id,
client_secret=os.environ.get("KEYCLOAK_CLIENT_SECRET"),
verify_ssl=os.environ.get("KEYCLOAK_VERIFY_SSL", "true").lower() == "true"
)