feat(runtime-control): 注册流程改为通过AuthZ服务
注册现在通过AuthZ进行处理,而登录/运行时查找仍然使用deploy-control。 更新了API调用逻辑,将注册请求从直接调用deploy-control和instance-api 改为统一调用AuthZ服务。 - 修改了注册API路由(/api/runtime/register)以使用callAuthzService - 更新README.md文档说明新的架构流程 - 添加AUTHZ_API_BASE_URL环境变量配置 - 更新注册页面描述信息 - 移除了不再使用的callDeployControl和callInstanceApi相关代码
This commit is contained in:
@ -5,6 +5,7 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@ -18,6 +19,7 @@ from app.models import (
|
||||
OAuthTokenRequest,
|
||||
OAuthTokenResponse,
|
||||
OutlookSettings,
|
||||
PortalRegisterRequest,
|
||||
RegisterBackendRequest,
|
||||
RegisterBackendResponse,
|
||||
RegisterUserRequest,
|
||||
@ -35,6 +37,9 @@ ISSUER = os.getenv("AUTHZ_ISSUER", "http://127.0.0.1:19090").rstrip("/")
|
||||
INTERNAL_TOKEN = os.getenv("AUTHZ_INTERNAL_TOKEN", "dev-internal-token")
|
||||
ACCESS_TOKEN_TTL_SECONDS = int(os.getenv("AUTHZ_ACCESS_TOKEN_TTL_SECONDS", "3600"))
|
||||
PRIVATE_KEY_PATH = Path(os.getenv("AUTHZ_PRIVATE_KEY_PATH", DATA_DIR / "signing_key.pem"))
|
||||
DEPLOY_API_BASE_URL = os.getenv("DEPLOY_API_BASE_URL", "http://127.0.0.1:8090").rstrip("/")
|
||||
DEPLOY_API_TOKEN = os.getenv("DEPLOY_API_TOKEN", "").strip()
|
||||
UPSTREAM_TIMEOUT_SECONDS = float(os.getenv("AUTHZ_UPSTREAM_TIMEOUT_SECONDS", "15"))
|
||||
|
||||
store = JsonStore(DATA_DIR)
|
||||
signer = JwtSigner(PRIVATE_KEY_PATH, ISSUER, ACCESS_TOKEN_TTL_SECONDS)
|
||||
@ -69,6 +74,106 @@ def _clean_optional(value: str | None) -> str | None:
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _as_object(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _as_string(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _http_error_detail(response: httpx.Response) -> str:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = {}
|
||||
detail = _as_string(_as_object(payload).get("detail"))
|
||||
return detail or response.text.strip() or f"upstream request failed with status {response.status_code}"
|
||||
|
||||
|
||||
async def _request_json(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
json_body: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=UPSTREAM_TIMEOUT_SECONDS,
|
||||
follow_redirects=True,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
json=json_body,
|
||||
headers=headers,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise HTTPException(status_code=504, detail="upstream request timed out") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
if response.is_error:
|
||||
raise HTTPException(status_code=response.status_code, detail=_http_error_detail(response))
|
||||
|
||||
if not response.content:
|
||||
return {}
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=502, detail="upstream response was not valid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status_code=502, detail="upstream response must be a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
async def _call_deploy_control(path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
headers: dict[str, str] = {}
|
||||
if DEPLOY_API_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {DEPLOY_API_TOKEN}"
|
||||
return await _request_json(
|
||||
"POST",
|
||||
f"{DEPLOY_API_BASE_URL}{path}",
|
||||
json_body=payload,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
async def _call_instance_api(base_url: str, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized_base_url = base_url.rstrip("/")
|
||||
if not normalized_base_url:
|
||||
raise HTTPException(status_code=502, detail="instance api base url is missing")
|
||||
return await _request_json(
|
||||
"POST",
|
||||
f"{normalized_base_url}{path}",
|
||||
json_body=payload,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_portal_token_response(
|
||||
response: dict[str, Any],
|
||||
routing: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
frontend_base_url = _as_string(routing.get("frontend_base_url"))
|
||||
api_base_url = _as_string(routing.get("api_base_url")) or _as_string(routing.get("public_url"))
|
||||
public_url = _as_string(routing.get("public_url")) or api_base_url
|
||||
backend_connection = _as_object(response.get("backend_connection"))
|
||||
|
||||
merged_backend_connection = {
|
||||
**backend_connection,
|
||||
"frontend_base_url": _as_string(backend_connection.get("frontend_base_url")) or frontend_base_url or public_url or None,
|
||||
"api_base_url": _as_string(backend_connection.get("api_base_url")) or api_base_url or public_url or None,
|
||||
"public_base_url": _as_string(backend_connection.get("public_base_url")) or public_url or api_base_url or None,
|
||||
}
|
||||
|
||||
return {
|
||||
**response,
|
||||
"backend_connection": merged_backend_connection,
|
||||
}
|
||||
|
||||
|
||||
def _require_internal(authorization: str | None = Header(default=None)) -> None:
|
||||
token = ""
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
@ -282,6 +387,55 @@ async def jwks() -> dict[str, Any]:
|
||||
return signer.build_jwks()
|
||||
|
||||
|
||||
@app.post("/portal/register")
|
||||
async def portal_register(req: PortalRegisterRequest) -> dict[str, Any]:
|
||||
username = req.username.strip()
|
||||
if not username:
|
||||
raise HTTPException(status_code=400, detail="username is required")
|
||||
if not req.password:
|
||||
raise HTTPException(status_code=400, detail="password is required")
|
||||
|
||||
deploy_payload: dict[str, Any] = {
|
||||
"username": username,
|
||||
"password": req.password,
|
||||
"authz_base_url": ISSUER,
|
||||
}
|
||||
email = _clean_optional(req.email)
|
||||
if email is not None:
|
||||
deploy_payload["email"] = email
|
||||
|
||||
optional_fields = {
|
||||
"instance_id": _clean_optional(req.instance_id),
|
||||
"backend_name": _clean_optional(req.backend_name),
|
||||
"provider": _clean_optional(req.provider),
|
||||
"model": _clean_optional(req.model),
|
||||
"api_key": _clean_optional(req.api_key),
|
||||
"api_base": _clean_optional(req.api_base),
|
||||
"image_name": _clean_optional(req.image_name),
|
||||
}
|
||||
for key, value in optional_fields.items():
|
||||
if value is not None:
|
||||
deploy_payload[key] = value
|
||||
if req.replace:
|
||||
deploy_payload["replace"] = True
|
||||
|
||||
routing = await _call_deploy_control("/api/instances/register", deploy_payload)
|
||||
api_base_url = _as_string(routing.get("api_base_url")) or _as_string(routing.get("public_url"))
|
||||
instance_payload: dict[str, Any] = {
|
||||
"username": username,
|
||||
"password": req.password,
|
||||
"authz_base_url": ISSUER,
|
||||
}
|
||||
if email is not None:
|
||||
instance_payload["email"] = email
|
||||
backend_name = _clean_optional(req.backend_name)
|
||||
if backend_name is not None:
|
||||
instance_payload["backend_name"] = backend_name
|
||||
|
||||
response = await _call_instance_api(api_base_url, "/api/auth/register", instance_payload)
|
||||
return _normalize_portal_token_response(response, routing)
|
||||
|
||||
|
||||
@app.post("/backends/register", response_model=RegisterBackendResponse)
|
||||
async def register_backend(req: RegisterBackendRequest) -> RegisterBackendResponse:
|
||||
backend_name, backend_id, base_url, frontend_base_url = _resolve_register_backend_payload(req)
|
||||
|
||||
@ -134,6 +134,20 @@ class RegisterUserResponse(BaseModel):
|
||||
backend: RegisterUserBackendResult
|
||||
|
||||
|
||||
class PortalRegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: str | None = None
|
||||
instance_id: str | None = None
|
||||
backend_name: str | None = None
|
||||
provider: str | None = None
|
||||
model: str | None = None
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
image_name: str | None = None
|
||||
replace: bool = False
|
||||
|
||||
|
||||
class RotateSecretResponse(BaseModel):
|
||||
backend_id: str
|
||||
client_id: str
|
||||
|
||||
Reference in New Issue
Block a user