feat(cloud-console): add user authentication and administration

This commit is contained in:
2026-07-13 17:54:53 +08:00
parent 035b177128
commit cdef630e67
35 changed files with 4126 additions and 113 deletions
+16 -1
View File
@@ -10,7 +10,7 @@ authentication can be added later without changing route signatures.
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Callable, Literal
from cloud.auth import (
PLUGINS_ADMIN_SCOPE,
@@ -49,6 +49,7 @@ def create_cloud_router(
scheduler: "TaskScheduler",
plugin_registry: "PluginRegistry",
auth_provider: AuthProvider | None = None,
csrf_validator: Callable[[Request, Principal], bool] | None = None,
version_prefix: str = "/v1",
) -> APIRouter:
"""Build the ``/v1`` APIRouter exposing the platform SDK surface."""
@@ -63,11 +64,25 @@ def create_cloud_router(
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
if principal.must_change_password:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="password must be changed before accessing this resource",
)
if not principal.has_scope(required_scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"missing required scope: {required_scope}",
)
if (
principal.session_id is not None
and request.method in {"POST", "PUT", "PATCH", "DELETE"}
and (csrf_validator is None or not csrf_validator(request, principal))
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF validation failed",
)
return principal
@router.post(
+84 -1
View File
@@ -139,6 +139,76 @@ class CloudClient:
resp = self._request("POST", "/plugins", json=payload)
return resp.json()
# --------------------------------------------------------------- user auth
def login(self, *, username: str, password: str) -> dict[str, Any]:
resp = self._request(
"POST",
"/auth/login",
json={"username": username, "password": password},
)
return resp.json()
def current_user(self) -> dict[str, Any]:
resp = self._request("GET", "/auth/me")
return resp.json()
def logout(self) -> None:
self._request("POST", "/auth/logout")
def change_password(self, *, current_password: str, new_password: str) -> None:
self._request(
"POST",
"/auth/password",
json={
"current_password": current_password,
"new_password": new_password,
},
)
def list_users(self, *, limit: int = 50, offset: int = 0) -> dict[str, Any]:
resp = self._request(
"GET",
"/users",
params={"limit": limit, "offset": offset},
)
return resp.json()
def create_user(
self,
*,
username: str,
display_name: str,
role: str,
password: str,
) -> dict[str, Any]:
resp = self._request(
"POST",
"/users",
json={
"username": username,
"display_name": display_name,
"role": role,
"password": password,
},
)
return resp.json()
def update_user(self, user_id: str, **changes: Any) -> dict[str, Any]:
resp = self._request("PATCH", f"/users/{user_id}", json=changes)
return resp.json()
def reset_user_password(self, user_id: str, *, password: str) -> dict[str, Any]:
resp = self._request(
"POST",
f"/users/{user_id}/password",
json={"password": password},
)
return resp.json()
def revoke_user_sessions(self, user_id: str) -> None:
self._request("DELETE", f"/users/{user_id}/sessions")
# ------------------------------------------------------------------ helpers
def _url(self, path: str) -> str:
@@ -152,12 +222,17 @@ class CloudClient:
json: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
) -> httpx.Response:
headers = dict(self._headers or {})
if method in {"POST", "PUT", "PATCH", "DELETE"} and not headers:
csrf_token = _cookie_value(self._http, "amcp_csrf")
if csrf_token:
headers["X-CSRF-Token"] = csrf_token
response = self._http.request(
method,
self._url(path),
json=json,
params=params,
headers=self._headers,
headers=headers or None,
auth=self._auth,
)
if response.is_success:
@@ -173,3 +248,11 @@ class CloudClient:
else CloudAPIError
)
raise error_type(response, str(detail))
def _cookie_value(client: Any, name: str) -> str | None:
cookies = getattr(client, "cookies", None)
if cookies is None:
return None
value = cookies.get(name)
return value if isinstance(value, str) else None
@@ -97,5 +97,51 @@ class PluginResponse(BaseModel):
wired: bool
class LoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=256)
class PasswordChangeRequest(BaseModel):
current_password: str = Field(min_length=1, max_length=256)
new_password: str = Field(min_length=1, max_length=256)
class UserResponse(BaseModel):
id: str
username: str
display_name: str
role: Literal["viewer", "operator", "admin"]
enabled: bool
must_change_password: bool
scopes: list[str]
created_at: datetime
updated_at: datetime
last_login_at: datetime | None = None
class UserListResponse(BaseModel):
items: list[UserResponse]
limit: int
offset: int
class UserCreateRequest(BaseModel):
username: str = Field(min_length=1, max_length=64)
display_name: str = Field(min_length=1, max_length=120)
role: Literal["viewer", "operator", "admin"]
password: str = Field(min_length=1, max_length=256)
class UserUpdateRequest(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=120)
role: Literal["viewer", "operator", "admin"] | None = None
enabled: bool | None = None
class PasswordResetRequest(BaseModel):
password: str = Field(min_length=1, max_length=256)
class ErrorResponse(BaseModel):
detail: str
@@ -0,0 +1,323 @@
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"