Files
q792602257andClaude Opus 4.6 9afbdc91fa
Tests / Test failed: 4, passed: 744
style(cloud): reformat modules and restore except-tuple parentheses
Apply consistent line-length formatting across governance, plugins, the
SDK routers (governance_api, user_api), user_auth, and migrations
0003/0005/0006.

Also restore the parentheses on two except clauses that had been dropped
into invalid Python 3 `except A, B:` syntax: plugins._coerce_to_manifest
(KeyError, ValueError) and PasswordHasher.verify (InvalidHashError,
VerificationError). Both modules now import cleanly again.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 13:30:13 +08:00

346 lines
13 KiB
Python

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"