The Enterprise Security Mandate
In educational institutions, school administrators and tech support engineers frequently face difficult support tickets:
- "A teacher claims they cannot see student submissions in Classroom 9-B."
- "A student reports that their question palette froze during a specific exam."
In monolithic frameworks like Laravel, debugging such user-specific issues is traditionally solved by stateful server sessions (Auth::loginUsingId($userId)). However, in a decoupled, cloud-ready single-page application (SPA), the backend is strictly stateless. How can an administrator impersonate any student or teacher on demand—seeing exactly what they see—without asking for their password, without compromising audit compliance, and with the ability to safely restore their administrative session in one click?
In mfarim/fastapi-elearning-react, we answered this question by designing an enterprise security architecture comprising:
- Cryptographically Stamped Stateless JWT Tokens: Using
python-joseand BCrypt. - Declarative Role-Based Access Control (RBAC): Enforced via FastAPI Dependency Injection.
- The 1-Click Impersonation & Session Recovery Engine: Preserving audit trails without server-side session stores.
- File Upload Security & Path Traversal Immunity: Hardening file ingestion against directory escape attacks.
1. Cryptographic Token Generation & Verification
In backend/app/core/security.py, password hashing and token generation use industry standards (passlib with BCrypt and python-jose with HMAC-SHA256):
PYTHON
# backend/app/core/security.py
from datetime import datetime, timedelta
from typing import Optional, Any, Union, List
from jose import jwt, JWTError
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(
subject: Union[str, Any],
roles: List[str],
extra_claims: Optional[dict] = None,
expires_delta: Optional[timedelta] = None
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {
"sub": str(subject),
"roles": roles,
"exp": expire,
"iat": datetime.utcnow()
}
if extra_claims:
to_encode.update(extra_claims)
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_access_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except JWTError:
return None
2. Declarative RBAC via Dependency Injection
In many microservices, authorization logic is scattered across controllers with manual if "ROLE_ADMIN" not in user.roles: blocks. This invites accidental permission bypasses.
In backend/app/api/deps.py, we implement a clean, composable higher-order dependency:
PYTHON
# backend/app/api/deps.py
from typing import List, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", auto_error=False)
async def get_current_user(
token: Optional[str] = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db)
) -> User:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_access_token(token)
if not payload or not payload.get("sub"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = int(payload.get("sub"))
# Eager load roles & profile relations in a single query
stmt = (
select(User)
.options(
selectinload(User.roles),
selectinload(User.teacher_profile),
selectinload(User.student_profile)
)
.where(User.id == user_id, User.deleted_at.is_(None))
)
user = (await db.execute(stmt)).scalars().first()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive"
)
# Attach impersonation claims dynamically to the User entity
setattr(user, "is_impersonating", payload.get("is_impersonating", False))
setattr(user, "original_admin_id", payload.get("original_admin_id", None))
return user
def require_roles(allowed_roles: List[str]):
"""Higher-order dependency enforcing strict role memberships"""
def role_checker(current_user: User = Depends(get_current_user)) -> User:
user_roles = [r.name for r in current_user.roles]
if not any(role in user_roles for role in allowed_roles):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Requires one of: {', '.join(allowed_roles)}",
)
return current_user
return role_checker
Now, endpoints declare their authorization requirements cleanly in the route signature:
PYTHON
@router.delete("/subjects/{id}")
async def delete_subject(
id: int,
db: AsyncSession = Depends(get_db),
admin: User = Depends(require_roles(["ROLE_ADMIN"])), # Enforces Super Admin only
):
...
3. Engineering the 1-Click Admin Impersonation Engine
In backend/app/api/v1/auth.py, the administrative impersonation endpoint allows authorized super administrators to assume the identity of any teacher or student:
PYTHON
# backend/app/api/v1/auth.py
@router.post("/admin/impersonate/{user_id}", response_model=ApiResponse[AuthResponse])
async def impersonate_user(
user_id: int,
db: AsyncSession = Depends(get_db),
admin_user: User = Depends(require_roles(["ROLE_ADMIN"]))
):
# Prevent self-impersonation loops
if user_id == admin_user.id:
raise HTTPException(status_code=400, detail="Cannot impersonate yourself")
# Fetch target user with associated roles and profiles
stmt = (
select(User)
.options(
selectinload(User.roles),
selectinload(User.teacher_profile),
selectinload(User.student_profile)
)
.where(User.id == user_id, User.deleted_at.is_(None))
)
target_user = (await db.execute(stmt)).scalars().first()
if not target_user:
raise HTTPException(status_code=404, detail="Target user not found")
target_roles = [r.name for r in target_user.roles]
# Mint a special stateless token carrying administrative audit claims
token = create_access_token(
subject=target_user.id,
roles=target_roles,
extra_claims={
"is_impersonating": True,
"original_admin_id": admin_user.id,
},
expires_delta=timedelta(hours=2) # Impersonation sessions expire automatically
)
auth_data = build_auth_response(target_user, token, admin_user.id)
return ApiResponse.ok(
data=auth_data,
message=f"Now impersonating {target_user.name} ({target_roles[0]})"
)
Seamless Session Restoration (stop-impersonate)
When the administrator finishes diagnosing the issue, clicking "Stop Impersonating" on the React frontend triggers:
PYTHON
@router.post("/admin/stop-impersonate", response_model=ApiResponse[AuthResponse])
async def stop_impersonate(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
# Verify that the current token was actually an impersonated session
if not getattr(current_user, "is_impersonating", False):
raise HTTPException(status_code=400, detail="Not currently impersonating")
original_admin_id = getattr(current_user, "original_admin_id", None)
if not original_admin_id:
raise HTTPException(status_code=400, detail="Missing original administrator identity")
# Re-fetch the original administrator
stmt = (
select(User)
.options(selectinload(User.roles))
.where(User.id == int(original_admin_id), User.deleted_at.is_(None))
)
admin_user = (await db.execute(stmt)).scalars().first()
if not admin_user or "ROLE_ADMIN" not in [r.name for r in admin_user.roles]:
raise HTTPException(status_code=403, detail="Original admin account no longer valid")
# Mint a fresh, clean Administrator Token
admin_token = create_access_token(
subject=admin_user.id,
roles=[r.name for r in admin_user.roles]
)
return ApiResponse.ok(
data=build_auth_response(admin_user, admin_token),
message="Restored administrator session"
)
4. File Upload Security Hardening & Path Traversal Immunity
File upload endpoints (submitting homework PDFs, uploading exam images, or reading Excel sheets) represent dangerous attack surfaces if filenames and storage paths are handled naively.
The Path Traversal Vulnerability
If an attacker uploads a file named ../../../../etc/passwd or ../../app/main.py, a naive handler executing:
PYTHON
with open(f"/var/www/uploads/{file.filename}", "wb") as f: # VULNERABLE!
will escape the upload directory and overwrite critical system binaries.
Robust Defense Implementation (backend/app/common/storage.py)
In backend/app/common/storage.py, we implement strict path sanitization, UUID prefixing, and MIME-type verification:
PYTHON
# backend/app/common/storage.py
import os
import uuid
import re
from pathlib import Path
from fastapi import UploadFile, HTTPException
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".pdf", ".docx", ".xlsx", ".mp3"}
ALLOWED_MIME_TYPES = {
"image/jpeg", "image/png", "application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"audio/mpeg",
}
MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024 # 25 MB limit
class StorageService:
def __init__(self, base_dir: str = "storage/uploads"):
self.base_dir = Path(base_dir).resolve()
self.base_dir.mkdir(parents=True, exist_ok=True)
def sanitize_filename(self, filename: str) -> str:
# Strip path traversal attempts and sanitize characters
clean_name = os.path.basename(filename)
clean_name = re.sub(r"[^a-zA-Z0-9_.-]", "_", clean_name)
# Unique UUID prefix prevents collision and path prediction
return f"{uuid.uuid4().hex}_{clean_name}"
async def save_file(self, upload_file: UploadFile) -> str:
# 1. Validate file extension
ext = Path(upload_file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"File extension {ext} not permitted")
# 2. Validate MIME type
if upload_file.content_type not in ALLOWED_MIME_TYPES:
raise HTTPException(status_code=400, detail=f"Content-Type {upload_file.content_type} not permitted")
# 3. Read content with size guard
content = await upload_file.read()
if len(content) > MAX_FILE_SIZE_BYTES:
raise HTTPException(status_code=400, detail="File exceeds maximum allowed size of 25MB")
# 4. Enforce strict directory confinement
safe_name = self.sanitize_filename(upload_file.filename)
destination = (self.base_dir / safe_name).resolve()
# Security check: Ensure destination is strictly inside base_dir
if not str(destination).startswith(str(self.base_dir)):
raise HTTPException(status_code=400, detail="Path traversal attempt detected")
with open(destination, "wb") as f:
f.write(content)
return safe_name
storage_service = StorageService()
Key Takeaways for Engineering Leads
- Keep impersonation stateless and auditable: Embed
original_admin_iddirectly in JWT claims rather than creating mutable server sessions, ensuring zero persistence overhead and full auditability. - Centralize authorization in declarative dependency functions: Use higher-order functions like
require_roles(["ROLE_ADMIN"])to guarantee consistent enforcement across all API routers. - Always resolve and verify upload file paths: Combining
os.path.basename, UUID prefixes, anddestination.resolve().startswith(base_dir)provides ironclad protection against path traversal attacks.
In Part 5, we explore data ingestion and quality engineering: High-Throughput Excel Streaming Pipelines with openpyxl, and Automated Async Testing with Pytest and GitHub Actions CI/CD.
