from __future__ import annotations from typing import TYPE_CHECKING from fastapi import APIRouter, HTTPException, Query, Request, Response, status from cloud.auth import AuthProvider, Principal, USERS_ADMIN_SCOPE from cloud.observability import current_correlation_id from cloud.repository import LastAdministratorConflictError, UserConflictError from cloud.sdk.models import ( LoginRequest, PasswordChangeRequest, PasswordResetRequest, UserCreateRequest, UserListResponse, UserResponse, UserUpdateRequest, ) from cloud.user_auth import ( USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthenticationError, UserValidationError, utc_now, validate_display_name, validate_role, ) if TYPE_CHECKING: from cloud.control_config import CloudControlConfig from cloud.user_auth import UserAccount, UserAuthService def create_user_auth_router( *, user_auth_service: UserAuthService, auth_provider: AuthProvider, config: CloudControlConfig, version_prefix: str = "/v1", ) -> APIRouter: router = APIRouter(prefix=version_prefix, tags=["cloud-user-authentication"]) def _principal( request: Request, *, required_scope: str | None = None, allow_password_change: bool = False, ) -> Principal: principal = auth_provider.authenticate(request) if principal is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized", headers={"WWW-Authenticate": "Bearer"}, ) if principal.must_change_password and not allow_password_change: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="password must be changed before accessing this resource", ) if required_scope is not None and not principal.has_scope(required_scope): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"missing required scope: {required_scope}", ) return principal def _require_session(principal: Principal) -> tuple[str, str]: if principal.session_id is None or not principal.id.startswith("user:"): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="a user session is required", ) return principal.id.removeprefix("user:"), principal.session_id def _require_csrf(request: Request, principal: Principal) -> None: if principal.session_id is None: return if not user_auth_service.validate_csrf( session_token=request.cookies.get(USER_SESSION_COOKIE), csrf_cookie=request.cookies.get(USER_CSRF_COOKIE), csrf_header=request.headers.get("x-csrf-token"), ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="CSRF validation failed", ) def _clear_cookies(response: Response) -> None: response.delete_cookie( USER_SESSION_COOKIE, path="/", secure=config.session_cookie_secure, httponly=True, samesite="lax", ) response.delete_cookie( USER_CSRF_COOKIE, path="/", secure=config.session_cookie_secure, httponly=False, samesite="lax", ) @router.post("/auth/login", response_model=UserResponse) def login(payload: LoginRequest, request: Request, response: Response) -> UserResponse: try: result = user_auth_service.login( username=payload.username, password=payload.password, client_bucket=_client_bucket(request, config.trust_proxy_headers), correlation_id=current_correlation_id(), ) except UserAuthenticationError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid username or password", ) from exc response.set_cookie( USER_SESSION_COOKIE, result.session_token, max_age=config.user_session_absolute_seconds, path="/", secure=config.session_cookie_secure, httponly=True, samesite="lax", ) response.set_cookie( USER_CSRF_COOKIE, result.csrf_token, max_age=config.user_session_absolute_seconds, path="/", secure=config.session_cookie_secure, httponly=False, samesite="lax", ) return _user_response(result.user) @router.get("/auth/me", response_model=UserResponse) def current_user(request: Request) -> UserResponse: principal = _principal(request, allow_password_change=True) user_id, _ = _require_session(principal) user = user_auth_service.repository.get_user(user_id) # type: ignore[attr-defined] if user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized") return _user_response(user) @router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT) def logout(request: Request, response: Response) -> Response: principal = _principal(request, allow_password_change=True) user_id, session_id = _require_session(principal) _require_csrf(request, principal) user_auth_service.logout( session_id=session_id, user_id=user_id, correlation_id=current_correlation_id(), ) _clear_cookies(response) response.status_code = status.HTTP_204_NO_CONTENT return response @router.post("/auth/password", status_code=status.HTTP_204_NO_CONTENT) def change_password( payload: PasswordChangeRequest, request: Request, response: Response, ) -> Response: principal = _principal(request, allow_password_change=True) user_id, _ = _require_session(principal) _require_csrf(request, principal) try: user_auth_service.change_password( user_id=user_id, current_password=payload.current_password, new_password=payload.new_password, correlation_id=current_correlation_id(), ) except UserAuthenticationError as exc: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid username or password", ) from exc except UserValidationError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc _clear_cookies(response) response.status_code = status.HTTP_204_NO_CONTENT return response @router.get("/users", response_model=UserListResponse) def list_users( request: Request, limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), ) -> UserListResponse: _principal(request, required_scope=USERS_ADMIN_SCOPE) users = user_auth_service.repository.list_users(limit=limit, offset=offset) # type: ignore[attr-defined] return UserListResponse( items=[_user_response(user) for user in users], limit=limit, offset=offset, ) @router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED) def create_user(payload: UserCreateRequest, request: Request) -> UserResponse: principal = _principal(request, required_scope=USERS_ADMIN_SCOPE) _require_csrf(request, principal) try: user = user_auth_service.create_user( username=payload.username, display_name=payload.display_name, role=payload.role, password=payload.password, ) except (UserValidationError, UserConflictError) as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc user_auth_service.record_admin_action( actor_principal_id=principal.id, target_user_id=user.id, action="user_create", correlation_id=current_correlation_id(), metadata={"role": user.role}, ) return _user_response(user) @router.patch("/users/{user_id}", response_model=UserResponse) def update_user( user_id: str, payload: UserUpdateRequest, request: Request, ) -> UserResponse: principal = _principal(request, required_scope=USERS_ADMIN_SCOPE) _require_csrf(request, principal) try: user = user_auth_service.repository.update_user( # type: ignore[attr-defined] user_id, display_name=( validate_display_name(payload.display_name) if payload.display_name is not None else None ), role=validate_role(payload.role) if payload.role is not None else None, enabled=payload.enabled, updated_at=utc_now(), ) except KeyError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from exc except LastAdministratorConflictError as exc: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc except UserValidationError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc user_auth_service.record_admin_action( actor_principal_id=principal.id, target_user_id=user.id, action="user_update", correlation_id=current_correlation_id(), metadata={"role": user.role, "enabled": str(user.enabled)}, ) return _user_response(user) @router.post("/users/{user_id}/password", response_model=UserResponse) def reset_password( user_id: str, payload: PasswordResetRequest, request: Request, ) -> UserResponse: principal = _principal(request, required_scope=USERS_ADMIN_SCOPE) _require_csrf(request, principal) try: user = user_auth_service.reset_password( user_id=user_id, new_password=payload.password, actor_principal_id=principal.id, correlation_id=current_correlation_id(), ) except KeyError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from exc except UserValidationError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc return _user_response(user) @router.delete("/users/{user_id}/sessions", status_code=status.HTTP_204_NO_CONTENT) def revoke_user_sessions(user_id: str, request: Request) -> Response: principal = _principal(request, required_scope=USERS_ADMIN_SCOPE) _require_csrf(request, principal) user = user_auth_service.repository.get_user(user_id) # type: ignore[attr-defined] if user is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") user_auth_service.repository.revoke_user_sessions( # type: ignore[attr-defined] user_id, revoked_at=utc_now(), ) user_auth_service.record_admin_action( actor_principal_id=principal.id, target_user_id=user_id, action="session_revoke", correlation_id=current_correlation_id(), ) return Response(status_code=status.HTTP_204_NO_CONTENT) return router def _user_response(user: UserAccount) -> UserResponse: return UserResponse( id=user.id, username=user.username, display_name=user.display_name, role=user.role, enabled=user.enabled, must_change_password=user.must_change_password, scopes=sorted(user.scopes), created_at=user.created_at, updated_at=user.updated_at, last_login_at=user.last_login_at, ) def _client_bucket(request: Request, trust_proxy_headers: bool) -> str: if trust_proxy_headers: forwarded = request.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",", maxsplit=1)[0].strip() or "unknown" return request.client.host if request.client is not None else "unknown"