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:
@ -45,6 +45,8 @@ curl http://127.0.0.1:19090/.well-known/jwks.json
|
||||
- 显式设置 `AUTHZ_INTERNAL_TOKEN`
|
||||
- 显式设置外部可访问的 `AUTHZ_ISSUER`
|
||||
- 例如 `https://authz.example.com`
|
||||
- 如果要让 AuthZ 负责编排实例注册,还要设置 `DEPLOY_API_BASE_URL`
|
||||
- 如果 deploy-control 开了鉴权,还要设置 `DEPLOY_API_TOKEN`
|
||||
- 不要把 `src/data/` 里的本地示例或真实数据直接拿去打镜像
|
||||
|
||||
## API 说明
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
AUTHZ_ISSUER=http://127.0.0.1:19090
|
||||
AUTHZ_INTERNAL_TOKEN=change-me
|
||||
AUTHZ_ACCESS_TOKEN_TTL_SECONDS=3600
|
||||
DEPLOY_API_BASE_URL=http://127.0.0.1:8090
|
||||
DEPLOY_API_TOKEN=change-me
|
||||
|
||||
3
authz-service/src/.gitignore
vendored
Normal file
3
authz-service/src/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@ -80,6 +80,14 @@ Authorization: Bearer <AUTHZ_INTERNAL_TOKEN>
|
||||
3. 配置 `POST /backends/{backend_id}/permissions`
|
||||
4. 用 `POST /oauth/token` 获取 token
|
||||
|
||||
### 流程 C:由 Auth Portal 发起的一站式注册
|
||||
|
||||
1. Auth Portal 调用 `POST /portal/register`
|
||||
2. AuthZ 先调用 deploy-control 创建或解析实例
|
||||
3. AuthZ 再调用实例自己的 `POST /api/auth/register`
|
||||
4. 实例在注册过程中回调 AuthZ 的 `/oauth/register` / `/backends/register`
|
||||
5. AuthZ 将最终 token 和 backend 连接信息回传给 Auth Portal
|
||||
|
||||
## 注册时需要提供什么信息
|
||||
|
||||
### 用户注册接口:`POST /oauth/register`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -6,6 +6,7 @@ requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0,<1.0.0",
|
||||
"uvicorn[standard]>=0.34.0,<1.0.0",
|
||||
"httpx>=0.28.0,<1.0.0",
|
||||
"pydantic>=2.12.0,<3.0.0",
|
||||
"cryptography>=45.0.0,<46.0.0",
|
||||
"PyJWT>=2.10.0,<3.0.0",
|
||||
|
||||
39
authz-service/src/uv.lock
generated
39
authz-service/src/uv.lock
generated
@ -41,6 +41,7 @@ source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-multipart" },
|
||||
@ -56,6 +57,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "cryptography", specifier = ">=45.0.0,<46.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.0,<1.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.10.0,<3.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" },
|
||||
@ -64,6 +66,15 @@ requires-dist = [
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
@ -251,6 +262,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.7.1"
|
||||
@ -294,6 +318,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
|
||||
@ -11,6 +11,8 @@ HOST_BIND_IP="${HOST_BIND_IP:-0.0.0.0}"
|
||||
AUTHZ_ISSUER="${AUTHZ_ISSUER:-http://127.0.0.1:${HOST_PORT}}"
|
||||
AUTHZ_INTERNAL_TOKEN="${AUTHZ_INTERNAL_TOKEN:-dev-internal-token}"
|
||||
AUTHZ_ACCESS_TOKEN_TTL_SECONDS="${AUTHZ_ACCESS_TOKEN_TTL_SECONDS:-3600}"
|
||||
DEPLOY_API_BASE_URL="${DEPLOY_API_BASE_URL:-http://127.0.0.1:8090}"
|
||||
DEPLOY_API_TOKEN="${DEPLOY_API_TOKEN:-}"
|
||||
FORCE_BUILD=0
|
||||
REPLACE=0
|
||||
|
||||
@ -65,6 +67,8 @@ docker run -d \
|
||||
-e "AUTHZ_ISSUER=${AUTHZ_ISSUER}" \
|
||||
-e "AUTHZ_INTERNAL_TOKEN=${AUTHZ_INTERNAL_TOKEN}" \
|
||||
-e "AUTHZ_ACCESS_TOKEN_TTL_SECONDS=${AUTHZ_ACCESS_TOKEN_TTL_SECONDS}" \
|
||||
-e "DEPLOY_API_BASE_URL=${DEPLOY_API_BASE_URL}" \
|
||||
-e "DEPLOY_API_TOKEN=${DEPLOY_API_TOKEN}" \
|
||||
"${IMAGE_NAME}" >/dev/null
|
||||
|
||||
printf 'container_name=%s\n' "${CONTAINER_NAME}"
|
||||
|
||||
Reference in New Issue
Block a user