- 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>
495 lines
15 KiB
Python
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}
|