2026-05-31 20:36:42 +00:00
|
|
|
"""FastAPI dependencies for authentication, authorization, and tenant filtering."""
|
|
|
|
|
|
|
|
|
|
from fastapi import Depends, HTTPException, status
|
|
|
|
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
|
|
|
from jose import JWTError, ExpiredSignatureError
|
|
|
|
|
from sqlalchemy import select
|
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
from sqlalchemy.orm import joinedload
|
|
|
|
|
|
|
|
|
|
from app.core.security import verify_token
|
|
|
|
|
from app.db.session import get_async_session
|
|
|
|
|
from app.models import User
|
|
|
|
|
|
|
|
|
|
security_scheme = HTTPBearer()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_current_user(
|
|
|
|
|
credentials: HTTPAuthorizationCredentials = Depends(security_scheme),
|
|
|
|
|
session: AsyncSession = Depends(get_async_session),
|
|
|
|
|
) -> User:
|
|
|
|
|
"""Extract and validate JWT from Authorization header, return the current user."""
|
|
|
|
|
token = credentials.credentials
|
|
|
|
|
try:
|
|
|
|
|
payload = verify_token(token)
|
|
|
|
|
except ExpiredSignatureError:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail="Token has expired",
|
|
|
|
|
)
|
|
|
|
|
except JWTError:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail="Invalid token",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
user_id = payload.get("sub")
|
|
|
|
|
if not user_id:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail="Invalid token payload",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Load user with role (and its permissions) eagerly
|
|
|
|
|
result = await session.execute(
|
2026-06-10 21:31:41 +00:00
|
|
|
select(User).options(joinedload(User.role)).where(User.id == user_id)
|
2026-05-31 20:36:42 +00:00
|
|
|
)
|
|
|
|
|
user = result.unique().scalars().first()
|
|
|
|
|
|
|
|
|
|
if not user:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail="User not found",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not user.is_active:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Account is deactivated",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_current_active_user(
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
) -> User:
|
|
|
|
|
"""Return the current user if they are active (redundant check, kept for clarity)."""
|
|
|
|
|
if not current_user.is_active:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="Account is deactivated",
|
|
|
|
|
)
|
|
|
|
|
return current_user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_permission(permission: str):
|
|
|
|
|
"""Factory that returns a dependency checking for a specific permission."""
|
|
|
|
|
|
|
|
|
|
async def permission_checker(
|
|
|
|
|
current_user: User = Depends(get_current_user),
|
|
|
|
|
) -> User:
|
|
|
|
|
if not current_user.role:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail="No role assigned",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
user_permissions: list[str] = current_user.role.permissions or []
|
|
|
|
|
if permission not in user_permissions:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail=f"Missing required permission: {permission}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return current_user
|
|
|
|
|
|
|
|
|
|
return permission_checker
|