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:
2026-03-16 11:07:08 +08:00
parent be30aa9465
commit df5e3d693c
16 changed files with 247 additions and 16 deletions

View File

@ -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)

View File

@ -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