Files
breakpilot-core/backend-core/rbac_api.py
Benjamin Admin 3c1a2d9c41 Remove re-export shim from keycloak_auth.py, update consumer imports
- rbac_api.py: import get_current_user from auth.dependencies directly
- keycloak_auth.py: remove re-export of dependencies module symbols
- pdf_service.py, file_processor.py: remove misleading compat comments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 00:13:30 +02:00

495 lines
15 KiB
Python

"""
RBAC API - Role and Assignment Management Endpoints
Provides API endpoints for:
- Listing all available roles (built-in + custom)
- Assigning/revoking roles to users
- Role summary with assignment counts
- Custom role CRUD
Shared infrastructure (DB pool, Pydantic models, role definitions)
used by rbac_teachers_api.py as well.
Architecture:
- Authentication: Keycloak (when configured) or local JWT
- Authorization: Custom rbac.py for fine-grained permissions
"""
import os
import asyncpg
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
# Import hybrid auth module
try:
from auth import get_current_user, TokenExpiredError, TokenInvalidError
except ImportError:
# Fallback for standalone testing
from auth.keycloak_auth import TokenExpiredError, TokenInvalidError
from auth.dependencies import get_current_user
# Configuration from environment - NO DEFAULT SECRETS
ENVIRONMENT = os.environ.get("ENVIRONMENT", "development")
router = APIRouter(prefix="/rbac", tags=["rbac"])
# Connection pool
_pool: Optional[asyncpg.Pool] = None
def _get_database_url() -> str:
"""Get DATABASE_URL from environment, raising error if not set."""
url = os.environ.get("DATABASE_URL")
if not url:
raise RuntimeError("DATABASE_URL nicht konfiguriert - bitte via Vault oder Umgebungsvariable setzen")
return url
async def get_pool() -> asyncpg.Pool:
"""Get or create database connection pool"""
global _pool
if _pool is None:
database_url = _get_database_url()
_pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10)
return _pool
async def close_pool():
"""Close database connection pool"""
global _pool
if _pool:
await _pool.close()
_pool = None
# Pydantic Models
class RoleAssignmentCreate(BaseModel):
user_id: str
role: str
resource_type: str = "tenant"
resource_id: str
valid_to: Optional[str] = None
class RoleAssignmentRevoke(BaseModel):
assignment_id: str
class TeacherCreate(BaseModel):
email: str
first_name: str
last_name: str
teacher_code: Optional[str] = None
title: Optional[str] = None
roles: List[str] = []
class TeacherUpdate(BaseModel):
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
teacher_code: Optional[str] = None
title: Optional[str] = None
is_active: Optional[bool] = None
class CustomRoleCreate(BaseModel):
role_key: str
display_name: str
description: str
category: str
class CustomRoleUpdate(BaseModel):
display_name: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
class TeacherResponse(BaseModel):
id: str
user_id: str
email: str
name: str
teacher_code: Optional[str]
title: Optional[str]
first_name: str
last_name: str
is_active: bool
roles: List[str]
class RoleInfo(BaseModel):
role: str
display_name: str
description: str
category: str
class RoleAssignmentResponse(BaseModel):
id: str
user_id: str
role: str
resource_type: str
resource_id: str
valid_from: str
valid_to: Optional[str]
granted_at: str
is_active: bool
# Role definitions with German display names
AVAILABLE_ROLES = {
# Klausur-Korrekturkette
"erstkorrektor": {
"display_name": "Erstkorrektor",
"description": "Fuehrt die erste Korrektur der Klausur durch",
"category": "klausur"
},
"zweitkorrektor": {
"display_name": "Zweitkorrektor",
"description": "Fuehrt die zweite Korrektur der Klausur durch",
"category": "klausur"
},
"drittkorrektor": {
"display_name": "Drittkorrektor",
"description": "Fuehrt die dritte Korrektur bei Notenabweichung durch",
"category": "klausur"
},
# Zeugnis-Workflow
"klassenlehrer": {
"display_name": "Klassenlehrer/in",
"description": "Erstellt Zeugnisse, traegt Kopfnoten und Bemerkungen ein",
"category": "zeugnis"
},
"fachlehrer": {
"display_name": "Fachlehrer/in",
"description": "Traegt Fachnoten ein",
"category": "zeugnis"
},
"zeugnisbeauftragter": {
"display_name": "Zeugnisbeauftragte/r",
"description": "Qualitaetskontrolle und Freigabe von Zeugnissen",
"category": "zeugnis"
},
"sekretariat": {
"display_name": "Sekretariat",
"description": "Druck, Versand und Archivierung von Dokumenten",
"category": "verwaltung"
},
# Leitung
"fachvorsitz": {
"display_name": "Fachvorsitz",
"description": "Fachpruefungsleitung und Qualitaetssicherung",
"category": "leitung"
},
"pruefungsvorsitz": {
"display_name": "Pruefungsvorsitz",
"description": "Pruefungsleitung und finale Freigabe",
"category": "leitung"
},
"schulleitung": {
"display_name": "Schulleitung",
"description": "Finale Freigabe und Unterschrift",
"category": "leitung"
},
"stufenleitung": {
"display_name": "Stufenleitung",
"description": "Koordination einer Jahrgangsstufe",
"category": "leitung"
},
# Administration
"schul_admin": {
"display_name": "Schul-Administrator",
"description": "Technische Administration der Schule",
"category": "admin"
},
"teacher_assistant": {
"display_name": "Referendar/in",
"description": "Lehrkraft in Ausbildung mit eingeschraenkten Rechten",
"category": "other"
},
}
# Note: get_user_from_token is replaced by the imported get_current_user dependency
# from auth module which supports both Keycloak and local JWT authentication
# API Endpoints
@router.get("/roles")
async def list_available_roles() -> List[RoleInfo]:
"""List all available roles with their descriptions"""
return [
RoleInfo(
role=role_key,
display_name=role_data["display_name"],
description=role_data["description"],
category=role_data["category"]
)
for role_key, role_data in AVAILABLE_ROLES.items()
]
@router.post("/assignments")
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
"""Assign a role to a user"""
if assignment.role not in AVAILABLE_ROLES:
raise HTTPException(status_code=400, detail=f"Unknown role: {assignment.role}")
pool = await get_pool()
async with pool.acquire() as conn:
# Check if assignment already exists
existing = await conn.fetchrow("""
SELECT id FROM role_assignments
WHERE user_id = $1 AND role = $2 AND resource_id = $3
AND revoked_at IS NULL
""", assignment.user_id, assignment.role, assignment.resource_id)
if existing:
raise HTTPException(
status_code=409,
detail="Role assignment already exists"
)
# Parse valid_to if provided
valid_to = None
if assignment.valid_to:
valid_to = datetime.fromisoformat(assignment.valid_to)
# Create assignment
result = await conn.fetchrow("""
INSERT INTO role_assignments
(user_id, role, resource_type, resource_id, tenant_id, valid_to, granted_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, user_id, role, resource_type, resource_id, valid_from, valid_to, granted_at
""",
assignment.user_id,
assignment.role,
assignment.resource_type,
assignment.resource_id,
assignment.resource_id, # tenant_id same as resource_id for tenant-level roles
valid_to,
user.get("user_id")
)
return RoleAssignmentResponse(
id=str(result["id"]),
user_id=str(result["user_id"]),
role=result["role"],
resource_type=result["resource_type"],
resource_id=str(result["resource_id"]),
valid_from=result["valid_from"].isoformat(),
valid_to=result["valid_to"].isoformat() if result["valid_to"] else None,
granted_at=result["granted_at"].isoformat(),
is_active=True
)
@router.delete("/assignments/{assignment_id}")
async def revoke_role(assignment_id: str, user: Dict[str, Any] = Depends(get_current_user)):
"""Revoke a role assignment"""
pool = await get_pool()
async with pool.acquire() as conn:
result = await conn.execute("""
UPDATE role_assignments
SET revoked_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
""", assignment_id)
if result == "UPDATE 0":
raise HTTPException(status_code=404, detail="Assignment not found or already revoked")
return {"status": "revoked", "assignment_id": assignment_id}
@router.get("/summary")
async def get_role_summary(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
"""Get a summary of roles and their assignment counts"""
pool = await get_pool()
async with pool.acquire() as conn:
counts = await conn.fetch("""
SELECT role, COUNT(*) as count
FROM role_assignments
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
AND revoked_at IS NULL
AND (valid_to IS NULL OR valid_to > NOW())
GROUP BY role
ORDER BY role
""")
total_teachers = await conn.fetchval("""
SELECT COUNT(*) FROM teachers
WHERE school_id = 'a0000000-0000-0000-0000-000000000001'
AND is_active = true
""")
role_counts = {c["role"]: c["count"] for c in counts}
# Also include custom roles from database
custom_roles = await conn.fetch("""
SELECT role_key, display_name, category
FROM custom_roles
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
AND is_active = true
""")
all_roles = [
{
"role": role_key,
"display_name": role_data["display_name"],
"category": role_data["category"],
"count": role_counts.get(role_key, 0),
"is_custom": False
}
for role_key, role_data in AVAILABLE_ROLES.items()
]
for cr in custom_roles:
all_roles.append({
"role": cr["role_key"],
"display_name": cr["display_name"],
"category": cr["category"],
"count": role_counts.get(cr["role_key"], 0),
"is_custom": True
})
return {
"total_teachers": total_teachers,
"roles": all_roles
}
# ==========================================
# CUSTOM ROLE MANAGEMENT ENDPOINTS
# ==========================================
@router.get("/custom-roles")
async def list_custom_roles(user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleInfo]:
"""List all custom roles"""
pool = await get_pool()
async with pool.acquire() as conn:
roles = await conn.fetch("""
SELECT role_key, display_name, description, category
FROM custom_roles
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
AND is_active = true
ORDER BY category, display_name
""")
return [
RoleInfo(
role=r["role_key"],
display_name=r["display_name"],
description=r["description"],
category=r["category"]
)
for r in roles
]
@router.post("/custom-roles")
async def create_custom_role(role: CustomRoleCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleInfo:
"""Create a new custom role"""
pool = await get_pool()
# Check if role_key conflicts with built-in roles
if role.role_key in AVAILABLE_ROLES:
raise HTTPException(status_code=409, detail="Role key conflicts with built-in role")
async with pool.acquire() as conn:
# Check if custom role already exists
existing = await conn.fetchrow("""
SELECT id FROM custom_roles
WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
""", role.role_key)
if existing:
raise HTTPException(status_code=409, detail="Custom role already exists")
await conn.execute("""
INSERT INTO custom_roles (role_key, display_name, description, category, tenant_id, created_by)
VALUES ($1, $2, $3, $4, 'a0000000-0000-0000-0000-000000000001', $5)
""", role.role_key, role.display_name, role.description, role.category, user.get("user_id"))
return RoleInfo(
role=role.role_key,
display_name=role.display_name,
description=role.description,
category=role.category
)
@router.put("/custom-roles/{role_key}")
async def update_custom_role(role_key: str, updates: CustomRoleUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleInfo:
"""Update a custom role"""
if role_key in AVAILABLE_ROLES:
raise HTTPException(status_code=400, detail="Cannot modify built-in roles")
pool = await get_pool()
async with pool.acquire() as conn:
current = await conn.fetchrow("""
SELECT role_key, display_name, description, category
FROM custom_roles
WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
AND is_active = true
""", role_key)
if not current:
raise HTTPException(status_code=404, detail="Custom role not found")
new_display = updates.display_name or current["display_name"]
new_desc = updates.description or current["description"]
new_cat = updates.category or current["category"]
await conn.execute("""
UPDATE custom_roles
SET display_name = $1, description = $2, category = $3
WHERE role_key = $4 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
""", new_display, new_desc, new_cat, role_key)
return RoleInfo(
role=role_key,
display_name=new_display,
description=new_desc,
category=new_cat
)
@router.delete("/custom-roles/{role_key}")
async def delete_custom_role(role_key: str, user: Dict[str, Any] = Depends(get_current_user)):
"""Delete a custom role (soft delete)"""
if role_key in AVAILABLE_ROLES:
raise HTTPException(status_code=400, detail="Cannot delete built-in roles")
pool = await get_pool()
async with pool.acquire() as conn:
# Soft delete the role
result = await conn.execute("""
UPDATE custom_roles SET is_active = false
WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
""", role_key)
if result == "UPDATE 0":
raise HTTPException(status_code=404, detail="Custom role not found")
# Also revoke all assignments with this role
await conn.execute("""
UPDATE role_assignments SET revoked_at = NOW()
WHERE role = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001'
AND revoked_at IS NULL
""", role_key)
return {"status": "deleted", "role_key": role_key}