Oversight admins can now create campaigns, upload proofs, and flag/resolve issues when they have an agency assigned. They retain all existing cross-agency read access for analytics, auditing, and user management. Oversight admins without an agency see a read-only campaigns view. Changes: - Add oversight_admin to canWrite permission in UserContext - Guard readOnly for oversight_admin without agency in App.tsx - Remove oversight_admin block from require_write_access dependency - Remove WebSocket oversight_admin upload block in main.py - Require agency for oversight_admin campaign creation in routes.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
142 lines
4.9 KiB
Python
Executable file
142 lines
4.9 KiB
Python
Executable file
"""
|
|
FastAPI authentication dependencies.
|
|
|
|
Provides dependency functions for securing REST endpoints with Azure AD token verification
|
|
and role-based access control.
|
|
"""
|
|
import logging
|
|
from typing import Optional
|
|
|
|
from fastapi import Depends, Header, HTTPException, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.models.database import get_db
|
|
from app.models.models import User
|
|
from app.repositories.user_repository import UserRepository
|
|
from app.services.auth_service import verify_access_token
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Valid roles ordered by privilege level (for reference)
|
|
VALID_ROLES = ("super_admin", "oversight_admin", "agency_admin", "basic_user")
|
|
|
|
|
|
async def get_current_user(authorization: Optional[str] = Header(None)) -> dict:
|
|
"""
|
|
FastAPI dependency to verify the access token and return user claims.
|
|
|
|
Use as a dependency on protected endpoints:
|
|
@app.get("/protected")
|
|
async def protected_route(user: dict = Depends(get_current_user)):
|
|
return {"message": f"Hello {user.get('name')}"}
|
|
|
|
Args:
|
|
authorization: The Authorization header value (Bearer <token>)
|
|
|
|
Returns:
|
|
The token claims dict containing user information
|
|
|
|
Raises:
|
|
HTTPException: 401 if token is missing or invalid
|
|
"""
|
|
logger.debug("[MSAL Backend] get_current_user dependency called")
|
|
|
|
# If auth is disabled, return mock user immediately
|
|
if settings.DISABLE_AUTH:
|
|
logger.debug("[MSAL Backend] Auth disabled - returning mock user")
|
|
return {"sub": "dev-user", "name": "Development User", "preferred_username": "dev@localhost"}
|
|
|
|
if not authorization:
|
|
logger.warning("[MSAL Backend] Missing authorization header")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Missing authorization header",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
logger.debug(f"[MSAL Backend] Authorization header present, length: {len(authorization)}")
|
|
|
|
# Extract token from "Bearer <token>" format
|
|
parts = authorization.split()
|
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
|
logger.warning(f"[MSAL Backend] Invalid auth header format: {parts[0] if parts else 'empty'}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid authorization header format. Expected: Bearer <token>",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
token = parts[1]
|
|
logger.debug("[MSAL Backend] Extracted Bearer token, calling verify_access_token...")
|
|
claims = await verify_access_token(token)
|
|
|
|
if not claims:
|
|
logger.warning("[MSAL Backend] Token verification failed - returning 401")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid or expired token",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
logger.debug(f"[MSAL Backend] Authentication successful for: {claims.get('name', 'unknown')}")
|
|
return claims
|
|
|
|
|
|
async def get_current_db_user(
|
|
user_claims: dict = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> User:
|
|
"""
|
|
Resolve Azure AD claims to a full User ORM object with agency loaded.
|
|
|
|
Creates the user on first login as basic_user with no agency.
|
|
In dev mode (DISABLE_AUTH=true), auto-promotes the dev user to super_admin.
|
|
"""
|
|
user_repo = UserRepository(db)
|
|
azure_oid = user_claims.get("oid") or user_claims.get("sub")
|
|
if not azure_oid:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Missing user identifier in token claims",
|
|
)
|
|
|
|
user = await user_repo.get_or_create_from_azure(
|
|
azure_ad_oid=azure_oid,
|
|
email=user_claims.get("email", user_claims.get("preferred_username", "")),
|
|
name=user_claims.get("name", "Unknown"),
|
|
)
|
|
|
|
# Dev mode: auto-promote to super_admin so all features are accessible
|
|
if settings.DISABLE_AUTH and user.role != "super_admin":
|
|
user.role = "super_admin"
|
|
await db.flush()
|
|
|
|
return user
|
|
|
|
|
|
def require_role(*allowed_roles: str):
|
|
"""
|
|
Dependency factory that restricts access to users with specific roles.
|
|
|
|
Usage:
|
|
@router.get("/admin-only")
|
|
async def admin_route(user: User = Depends(require_role("super_admin"))):
|
|
...
|
|
"""
|
|
async def _check_role(current_user: User = Depends(get_current_db_user)) -> User:
|
|
if current_user.role not in allowed_roles:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"Access denied. Required role: {', '.join(allowed_roles)}",
|
|
)
|
|
return current_user
|
|
|
|
return _check_role
|
|
|
|
|
|
async def require_write_access(
|
|
current_user: User = Depends(get_current_db_user),
|
|
) -> User:
|
|
"""Dependency for write/mutation operations."""
|
|
return current_user
|