Merged
feat:初始化manager #419
王欣Add on Aug 14
feat:初始化manager #419
Merged
Total: 220 files changed+40868-0
| @@ -0,0 +1,209 @@ | |||
| 1 | +# Manager 管理面 | ||
| 2 | + | ||
| 3 | +`applications/manager` 是 JiuwenClaw 的管理平面,包含三个子模块: | ||
| 4 | + | ||
| 5 | +| 目录 | 说明 | 默认端口 | | ||
| 6 | +|------|------|----------| | ||
| 7 | +| `identity_center/` | 认证中心(OAuth2 / JWT、用户与组织) | `8770` | | ||
| 8 | +| `manager_server/` | 管理 API(实例、模板、策略等)+ Manager WebSocket | REST `8765`、WS `8766` | | ||
| 9 | +| `manager_web/` | 管理面 Web 前端(React + Vite) | `5273` | | ||
| 10 | + | ||
| 11 | +本地开发时,**先启动身份中心,再启动管理 API,最后启动前端**。 | ||
| 12 | + | ||
| 13 | +--- | ||
| 14 | + | ||
| 15 | +## 环境要求 | ||
| 16 | + | ||
| 17 | +- Python `>=3.11.4,<3.14` | ||
| 18 | +- [uv](https://docs.astral.sh/uv/)(推荐)或 pip | ||
| 19 | +- Node.js `>=18`(前端开发 / 构建) | ||
| 20 | +- 仓库根目录下的 `foundation` 包(Python 依赖) | ||
| 21 | + | ||
| 22 | +--- | ||
| 23 | + | ||
| 24 | +## 首次安装 | ||
| 25 | + | ||
| 26 | +### 方式一:使用 uv(推荐) | ||
| 27 | + | ||
| 28 | +```bash | ||
| 29 | +# 创建虚拟环境(如果尚未创建) | ||
| 30 | +uv venv | ||
| 31 | + | ||
| 32 | +# Windows | ||
| 33 | +.venv\Scripts\activate | ||
| 34 | +# Linux / macOS | ||
| 35 | +# source .venv/bin/activate | ||
| 36 | + | ||
| 37 | +# 安装 Python 依赖(一次性装齐,避免 uv sync --project 互相覆盖) | ||
| 38 | +uv pip install -e foundation -e applications/manager/identity_center -e applications/manager/manager_server | ||
| 39 | + | ||
| 40 | +# 安装前端依赖 | ||
| 41 | +cd applications/manager/manager_web && npm install && cd ../../.. | ||
| 42 | +``` | ||
| 43 | + | ||
| 44 | +> **注意**:不要用 `uv sync --project ... --active` 分别安装两个子项目——每次 sync 只保留当前项目的依赖,后执行的会把先安装的服务卸掉。应使用上面的 `uv pip install -e` 一次性安装。 | ||
| 45 | +> | ||
| 46 | +> 激活后请确认 Python 来自本仓库 `.venv`(Windows:`where python` 应指向 `.venv\Scripts\python.exe`)。若当前是 conda 等其他环境,先 `deactivate` 再激活 `.venv`。 | ||
| 47 | + | ||
| 48 | +### 方式二:使用 pip + venv | ||
| 49 | + | ||
| 50 | +```bash | ||
| 51 | +# 创建虚拟环境 | ||
| 52 | +python -m venv .venv | ||
| 53 | + | ||
| 54 | +# Windows | ||
| 55 | +.venv\Scripts\activate | ||
| 56 | +# Linux / macOS | ||
| 57 | +# source .venv/bin/activate | ||
| 58 | + | ||
| 59 | +# 安装 Python 依赖 | ||
| 60 | +pip install -e foundation | ||
| 61 | +pip install -e applications/manager/identity_center | ||
| 62 | +pip install -e applications/manager/manager_server | ||
| 63 | + | ||
| 64 | +# 安装前端依赖 | ||
| 65 | +cd applications/manager/manager_web && npm install && cd ../../.. | ||
| 66 | +``` | ||
| 67 | + | ||
| 68 | +默认使用 SQLite,无需额外配置数据库: | ||
| 69 | + | ||
| 70 | +- 身份库:`identity.db` | ||
| 71 | +- 管理库:`manager.db` | ||
| 72 | + | ||
| 73 | +默认登录账号见下方「本地开发启动」章节。 | ||
| 74 | + | ||
| 75 | +--- | ||
| 76 | + | ||
| 77 | +## 本地开发启动(推荐) | ||
| 78 | + | ||
| 79 | +开 **3 个终端**,均在仓库根目录、**已激活虚拟环境**的前提下执行。 | ||
| 80 | + | ||
| 81 | +### 终端 1:身份中心 | ||
| 82 | + | ||
| 83 | +```bash | ||
| 84 | +identity-center | ||
| 85 | +# 或: python -m identity_center.main | ||
| 86 | +``` | ||
| 87 | + | ||
| 88 | +### 终端 2:管理 API | ||
| 89 | + | ||
| 90 | +```bash | ||
| 91 | +manager-server | ||
| 92 | +# 或: python -m manager_server.main | ||
| 93 | +``` | ||
| 94 | + | ||
| 95 | +### 终端 3:前端开发服务器 | ||
| 96 | + | ||
| 97 | +```bash | ||
| 98 | +cd applications/manager/manager_web | ||
| 99 | +npm run dev | ||
| 100 | +``` | ||
| 101 | + | ||
| 102 | +浏览器访问:**http://127.0.0.1:5273** | ||
| 103 | + | ||
| 104 | +### 默认登录账号 | ||
| 105 | + | ||
| 106 | +首次启动 `identity-center` 后会自动创建以下账号(仅用于本地开发): | ||
| 107 | + | ||
| 108 | +| 用户名 | 密码 | 角色 | 用途 | | ||
| 109 | +|--------|------|------|------| | ||
| 110 | +| `admin` | `admin` | 管理员 | 登录管理面(`/manager`),可进行实例、模板、策略等配置 | | ||
| 111 | +| `user1` | `user1` | 普通用户 | 登录用户端(`/user`),无管理权限 | | ||
| 112 | + | ||
| 113 | +Vite 开发服务器已配置代理,`/api` → `8765`、`/idp` → `8770`(会自动去掉 `/idp` 前缀)。 | ||
| 114 | + | ||
| 115 | +> 若登录报 `/idp/v1/auth/token` 404,请确认 `identity-center` 已在 `8770` 端口运行,并重启 `npm run dev` 使代理配置生效。 | ||
| 116 | +`/web/invoke`、`/file-api` 依赖 Gateway / User Server,未启动时聊天相关功能不可用,管理面主体功能可正常使用。 | ||
| 117 | + | ||
| 118 | +--- | ||
| 119 | + | ||
| 120 | +## 生产 / 集成模式(统一入口) | ||
| 121 | + | ||
| 122 | +构建静态资源后,由 `manager-web` 提供单一 HTTP 入口(默认 `5273`),统一转发 `/api`、`/idp` 等请求。 | ||
| 123 | + | ||
| 124 | +```bash | ||
| 125 | +# 构建前端(完整集成还需构建 user_web) | ||
| 126 | +cd applications/access/user_web && npm install && npm run build | ||
| 127 | +cd ../../manager/manager_web && npm install && npm run build | ||
| 128 | +cd ../../.. | ||
| 129 | + | ||
| 130 | +# 启动后端(需先激活虚拟环境) | ||
| 131 | +identity-center | ||
| 132 | +manager-server | ||
| 133 | + | ||
| 134 | +# 启动统一 Web 入口 | ||
| 135 | +manager-web \ | ||
| 136 | + --host 127.0.0.1 --port 5273 \ | ||
| 137 | + --dist applications/manager/manager_web/dist \ | ||
| 138 | + --chat-dist applications/access/user_web/dist | ||
| 139 | +``` | ||
| 140 | + | ||
| 141 | +`5273` 为外部唯一入口: | ||
| 142 | + | ||
| 143 | +- `/auth`、`/user`、`/manager` — Manager SPA | ||
| 144 | +- `/chat/` — 同源 User Web iframe | ||
| 145 | +- `/api` — 转发至 Manager API(`8765`) | ||
| 146 | +- `/idp` — 转发至身份中心(`8770`) | ||
| 147 | +- `/web/invoke`、`/file-api` — 转发至 Gateway / User Server | ||
| 148 | + | ||
| 149 | +也可一键启动 API + 已构建的 Web(需先 `npm run build`): | ||
| 150 | + | ||
| 151 | +```bash | ||
| 152 | +manager-start # API + Web | ||
| 153 | +manager-start manager # 仅 API | ||
| 154 | +manager-start web # 仅 Web | ||
| 155 | +``` | ||
| 156 | + | ||
| 157 | +--- | ||
| 158 | + | ||
| 159 | +## 常用环境变量 | ||
| 160 | + | ||
| 161 | +可在仓库根目录创建 `.env`,各服务启动时自动读取: | ||
| 162 | + | ||
| 163 | +```env | ||
| 164 | +# 身份中心 | ||
| 165 | +IDENTITY_REST_HOST=0.0.0.0 | ||
| 166 | +IDENTITY_REST_PORT=8770 | ||
| 167 | +IDENTITY_DB_TYPE=sqlite | ||
| 168 | +IDENTITY_SQLITE_PATH=identity.db | ||
| 169 | + | ||
| 170 | +# 管理 API | ||
| 171 | +MANAGER_REST_HOST=0.0.0.0 | ||
| 172 | +MANAGER_REST_PORT=8765 | ||
| 173 | +MANAGER_WS_PORT=8766 | ||
| 174 | +MANAGER_DB_TYPE=sqlite | ||
| 175 | +MANAGER_SQLITE_PATH=manager.db | ||
| 176 | +IDENTITY_PUBLIC_KEY_URL=http://127.0.0.1:8770/v1/auth/public_key | ||
| 177 | + | ||
| 178 | +# 统一 Web 入口(manager-web) | ||
| 179 | +MANAGER_WEB_HOST=127.0.0.1 | ||
| 180 | +MANAGER_WEB_PORT=5273 | ||
| 181 | +MANAGER_WEB_PROXY_TARGET=http://127.0.0.1:8765 | ||
| 182 | +MANAGER_WEB_IDP_TARGET=http://127.0.0.1:8770 | ||
| 183 | +``` | ||
| 184 | + | ||
| 185 | +--- | ||
| 186 | + | ||
| 187 | +## 健康检查 | ||
| 188 | + | ||
| 189 | +| 服务 | 地址 | | ||
| 190 | +|------|------| | ||
| 191 | +| 身份中心 API 文档 | http://127.0.0.1:8770/docs | | ||
| 192 | +| 管理 API 健康检查 | http://127.0.0.1:8765/api/health | | ||
| 193 | +| Manager WebSocket 状态 | http://127.0.0.1:8765/api/manager-ws/status | | ||
| 194 | +| 前端 | http://127.0.0.1:5273 | | ||
| 195 | + | ||
| 196 | +--- | ||
| 197 | + | ||
| 198 | +## 命令速查 | ||
| 199 | + | ||
| 200 | +| 命令 | 说明 | | ||
| 201 | +|------|------| | ||
| 202 | +| `identity-center` | 启动身份中心 | | ||
| 203 | +| `manager-server` | 启动管理 API + WebSocket | | ||
| 204 | +| `manager-web` | 启动统一 Web 入口(静态资源 + 反向代理) | | ||
| 205 | +| `manager-start` | 一键启动 API + Web | | ||
| 206 | +| `npm run dev` | 前端开发模式(在 `manager_web/` 目录下) | | ||
| 207 | +| `npm run build` | 构建前端静态资源到 `manager_web/dist/` | | ||
| 208 | + | ||
| 209 | +> 以上 Python 命令均需在已激活的虚拟环境中执行(uv 或 pip 安装后均可直接使用)。 | ||
| @@ -0,0 +1,45 @@ | |||
| 1 | +[build-system] | ||
| 2 | +requires = ["setuptools>=61.0", "wheel"] | ||
| 3 | +build-backend = "setuptools.build_meta" | ||
| 4 | + | ||
| 5 | +[project] | ||
| 6 | +# 重构:原 jiuwenclaw-identity,迁入 runtime 后改名为 identity-center | ||
| 7 | +name = "identity-center" | ||
| 8 | +version = "0.1.0" | ||
| 9 | +description = "JiuwenClaw identity / auth service (OAuth2 + JWT, standalone identity DB)" | ||
| 10 | +requires-python = ">=3.11.4,<3.14" | ||
| 11 | +license = { text = "Apache-2.0" } | ||
| 12 | +classifiers = [ | ||
| 13 | + "License :: OSI Approved :: Apache Software License", | ||
| 14 | +] | ||
| 15 | +dependencies = [ | ||
| 16 | + "fastapi>=0.115", | ||
| 17 | + "uvicorn[standard]>=0.30", | ||
| 18 | + "pydantic>=2.0", | ||
| 19 | + "pydantic-settings>=2.0", | ||
| 20 | + "structlog>=24.0", | ||
| 21 | + "PyJWT>=2.8", | ||
| 22 | + "cryptography>=42.0", | ||
| 23 | + "python-multipart>=0.0.9", | ||
| 24 | + # 重构:foundation 由 git 源(agent-runtime@develop)改为本仓源码,见 [tool.uv.sources] | ||
| 25 | + "openjiuwen-runtime-foundation", | ||
| 26 | + "asyncpg>=0.29", | ||
| 27 | +] | ||
| 28 | + | ||
| 29 | +[project.optional-dependencies] | ||
| 30 | +dev = [ | ||
| 31 | + "pytest>=9.0.2", | ||
| 32 | + "pytest-asyncio>=1.3.0", | ||
| 33 | + "ruff>=0.6", | ||
| 34 | +] | ||
| 35 | + | ||
| 36 | +[project.scripts] | ||
| 37 | +identity-center = "identity_center.main:main" | ||
| 38 | + | ||
| 39 | +# 重构:foundation 改用本仓 editable 源码,不再从 gitcode 拉取 | ||
| 40 | +[tool.uv.sources] | ||
| 41 | +openjiuwen-runtime-foundation = { path = "../../../foundation", editable = true } | ||
| 42 | + | ||
| 43 | +[tool.setuptools.packages.find] | ||
| 44 | +where = ["src"] | ||
| 45 | +include = ["identity_center*"] | ||
| @@ -0,0 +1,8 @@ | |||
| 1 | +"""JiuwenClaw 身份/认证服务(独立部署)。 | ||
| 2 | + | ||
| 3 | +职责:OAuth2 密码流签发 JWT、身份/凭据/组织/成员的权威数据源; | ||
| 4 | +claw_manager 与企业版 web 仅作为资源服务器验签 JWT、读取 claims。 | ||
| 5 | +认证后端可二次开发(默认本地口令库,厂商可接自有库/LDAP/OIDC)。 | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +__version__ = "0.1.0" | ||
| @@ -0,0 +1,70 @@ | |||
| 1 | +"""身份/认证服务 FastAPI 应用工厂。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from contextlib import asynccontextmanager | ||
| 6 | + | ||
| 7 | +from fastapi import FastAPI | ||
| 8 | +from fastapi.middleware.cors import CORSMiddleware | ||
| 9 | + | ||
| 10 | +from identity_center import __version__ | ||
| 11 | +from identity_center.infrastructure.config import settings | ||
| 12 | +from identity_center.infrastructure.db import create_db_handler, database_config_summary | ||
| 13 | +from identity_center.infrastructure.logger import configure_logging, get_logger | ||
| 14 | +from identity_center.models.table_init import init_all_tables | ||
| 15 | +from identity_center.routers.register import router_register | ||
| 16 | + | ||
| 17 | +_log = get_logger(__name__) | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +@asynccontextmanager | ||
| 21 | +async def lifespan(application: FastAPI): | ||
| 22 | + configure_logging() | ||
| 23 | + db_handler = create_db_handler() | ||
| 24 | + application.state.db_handler = db_handler | ||
| 25 | + await db_handler.init_database() | ||
| 26 | + await db_handler.connect() | ||
| 27 | + await init_all_tables(db_handler) | ||
| 28 | + | ||
| 29 | + from identity_center.core.auth import seed_defaults | ||
| 30 | + | ||
| 31 | + await seed_defaults(db_handler) | ||
| 32 | + | ||
| 33 | + # JWT 签名密钥:落库自举(缺失则生成 RSA-2048 落身份库)+ 填充进程缓存。 | ||
| 34 | + # 多副本天然读同一行 → k8s 一致,无需 Secret 同步。 | ||
| 35 | + from identity_center.security.jwt_keys import load_signing_key | ||
| 36 | + | ||
| 37 | + await load_signing_key(db_handler) | ||
| 38 | + | ||
| 39 | + _log.info( | ||
| 40 | + "startup", | ||
| 41 | + version=__version__, | ||
| 42 | + db=database_config_summary(), | ||
| 43 | + issuer=settings.jwt_issuer, | ||
| 44 | + audience=settings.jwt_audience, | ||
| 45 | + access_ttl=settings.access_ttl_seconds, | ||
| 46 | + ) | ||
| 47 | + yield | ||
| 48 | + await db_handler.disconnect() | ||
| 49 | + _log.info("shutdown") | ||
| 50 | + | ||
| 51 | + | ||
| 52 | +def create_app() -> FastAPI: | ||
| 53 | + application = FastAPI( | ||
| 54 | + title="identity-center", | ||
| 55 | + description="JiuwenClaw 身份/认证服务(OAuth2 + JWT,独立身份库)", | ||
| 56 | + version=__version__, | ||
| 57 | + lifespan=lifespan, | ||
| 58 | + ) | ||
| 59 | + application.add_middleware( | ||
| 60 | + CORSMiddleware, | ||
| 61 | + allow_origins=["*"], | ||
| 62 | + allow_credentials=True, | ||
| 63 | + allow_methods=["*"], | ||
| 64 | + allow_headers=["*"], | ||
| 65 | + ) | ||
| 66 | + router_register(application) | ||
| 67 | + return application | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +app = create_app() | ||
The file is empty
| @@ -0,0 +1,3 @@ | |||
| 1 | +from identity_center.core.auth.service import IdentityAuthService, seed_defaults | ||
| 2 | + | ||
| 3 | +__all__ = ("IdentityAuthService", "seed_defaults") | ||
| @@ -0,0 +1,38 @@ | |||
| 1 | +"""本地口令哈希(PBKDF2-HMAC-SHA256,标准库实现,无外部依赖)。 | ||
| 2 | + | ||
| 3 | +存储格式:``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``。 | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import hashlib | ||
| 9 | +import hmac | ||
| 10 | +import secrets | ||
| 11 | + | ||
| 12 | +_ALGO = "pbkdf2_sha256" | ||
| 13 | +_ITERATIONS = 200_000 | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +def hash_password(password: str) -> str: | ||
| 17 | + salt = secrets.token_bytes(16) | ||
| 18 | + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _ITERATIONS) | ||
| 19 | + return f"{_ALGO}${_ITERATIONS}${salt.hex()}${dk.hex()}" | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +def verify_password(password: str, stored: str | None) -> bool: | ||
| 23 | + if not stored: | ||
| 24 | + return False | ||
| 25 | + try: | ||
| 26 | + algo, iter_s, salt_hex, hash_hex = stored.split("$", 3) | ||
| 27 | + except ValueError: | ||
| 28 | + return False | ||
| 29 | + if algo != _ALGO: | ||
| 30 | + return False | ||
| 31 | + try: | ||
| 32 | + iterations = int(iter_s) | ||
| 33 | + salt = bytes.fromhex(salt_hex) | ||
| 34 | + expected = bytes.fromhex(hash_hex) | ||
| 35 | + except ValueError: | ||
| 36 | + return False | ||
| 37 | + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) | ||
| 38 | + return hmac.compare_digest(dk, expected) | ||
| @@ -0,0 +1,65 @@ | |||
| 1 | +"""可插拔认证 provider(认证可二次开发的扩展点)。 | ||
| 2 | + | ||
| 3 | +``AuthProvider.authenticate`` 负责"验证凭据 → 返回 user_id";签发 JWT 的流程与 | ||
| 4 | +具体认证方式解耦。默认 ``LocalPasswordProvider`` 查本地 ``auth_identity``。 | ||
| 5 | +厂商接入自有库 / LDAP / OIDC:再实现一个 provider 并 ``register_provider`` 即可, | ||
| 6 | +不动 ``IdentityAuthService`` 与表结构。 | ||
| 7 | +""" | ||
| 8 | + | ||
| 9 | +from __future__ import annotations | ||
| 10 | + | ||
| 11 | +from typing import Protocol | ||
| 12 | + | ||
| 13 | +from openjiuwen_runtime.foundation.db.handler import DBHandler | ||
| 14 | + | ||
| 15 | +from identity_center.core.auth.password import verify_password | ||
| 16 | +from identity_center.models.identity_models import AUTH_IDENTITY_TABLE_DEF | ||
| 17 | + | ||
| 18 | +_AUTH_IDENTITY_TABLE = AUTH_IDENTITY_TABLE_DEF.table_name | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +class AuthProvider(Protocol): | ||
| 22 | + name: str | ||
| 23 | + | ||
| 24 | + async def authenticate( | ||
| 25 | + self, handler: DBHandler, username: str, password: str | ||
| 26 | + ) -> str | None: | ||
| 27 | + """验证成功返回 user_id;失败返回 None。""" | ||
| 28 | + ... | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +class LocalPasswordProvider: | ||
| 32 | + """本地口令认证:按 (provider='local', external_subject=username) 查身份并校验口令。""" | ||
| 33 | + | ||
| 34 | + name = "local" | ||
| 35 | + | ||
| 36 | + async def authenticate( | ||
| 37 | + self, handler: DBHandler, username: str, password: str | ||
| 38 | + ) -> str | None: | ||
| 39 | + rows = await handler.list_records( | ||
| 40 | + _AUTH_IDENTITY_TABLE, | ||
| 41 | + {"provider": self.name, "external_subject": username}, | ||
| 42 | + limit=1, | ||
| 43 | + offset=0, | ||
| 44 | + ) | ||
| 45 | + if not rows: | ||
| 46 | + return None | ||
| 47 | + row = rows[0] | ||
| 48 | + if not verify_password(password, getattr(row, "credential", None)): | ||
| 49 | + return None | ||
| 50 | + return str(getattr(row, "user_id", "") or "") or None | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +# 注册表:二次开发时往这里加自定义 provider。 | ||
| 54 | +_PROVIDERS: dict[str, AuthProvider] = {} | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +def register_provider(provider: AuthProvider) -> None: | ||
| 58 | + _PROVIDERS[provider.name] = provider | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +def get_provider(name: str) -> AuthProvider | None: | ||
| 62 | + return _PROVIDERS.get(name) | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +register_provider(LocalPasswordProvider()) | ||
| @@ -0,0 +1,195 @@ | |||
| 1 | +"""认证服务:provider 校验凭据 → 签发 access JWT + refresh token;刷新 / 登出 / 播种。 | ||
| 2 | + | ||
| 3 | +access = 自包含 JWT(不落库,资源服务器公钥验签);refresh = 不透明串落 ``auth_session``, | ||
| 4 | +可撤销/轮换。``IdentityAuthService.login`` 成功返回 token 束 + 用户信息。 | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +from __future__ import annotations | ||
| 8 | + | ||
| 9 | +import secrets | ||
| 10 | +from datetime import timedelta, timezone | ||
| 11 | +from typing import Any | ||
| 12 | + | ||
| 13 | +from openjiuwen_runtime.foundation.db.handler import DBHandler | ||
| 14 | + | ||
| 15 | +from identity_center.core.auth.password import hash_password | ||
| 16 | +from identity_center.core.auth.providers import get_provider | ||
| 17 | +from identity_center.infrastructure.config import settings | ||
| 18 | +from identity_center.infrastructure.logger import get_logger | ||
| 19 | +from identity_center.infrastructure.utils import utc_now | ||
| 20 | +from identity_center.models.identity_models import ( | ||
| 21 | + APP_USER_TABLE_DEF, | ||
| 22 | + AUTH_IDENTITY_TABLE_DEF, | ||
| 23 | + AUTH_SESSION_TABLE_DEF, | ||
| 24 | + NO_ORG_GROUP_ID, | ||
| 25 | + ORG_TABLE_DEF, | ||
| 26 | + USER_ORG_MEMBERSHIP_TABLE_DEF, | ||
| 27 | +) | ||
| 28 | +from identity_center.security.tokens import issue_access_token | ||
| 29 | + | ||
| 30 | +_log = get_logger(__name__) | ||
| 31 | + | ||
| 32 | +_APP_USER = APP_USER_TABLE_DEF.table_name | ||
| 33 | +_AUTH_IDENTITY = AUTH_IDENTITY_TABLE_DEF.table_name | ||
| 34 | +_AUTH_SESSION = AUTH_SESSION_TABLE_DEF.table_name | ||
| 35 | +_ORG = ORG_TABLE_DEF.table_name | ||
| 36 | +_MEMBERSHIP = USER_ORG_MEMBERSHIP_TABLE_DEF.table_name | ||
| 37 | + | ||
| 38 | +_DEFAULT_PROVIDER = "local" | ||
| 39 | +_CAP = 1000 | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +def public_user(row: Any) -> dict[str, Any]: | ||
| 43 | + return { | ||
| 44 | + "user_id": getattr(row, "user_id", None), | ||
| 45 | + "display_name": getattr(row, "display_name", None), | ||
| 46 | + "is_admin": bool(getattr(row, "is_admin", False)), | ||
| 47 | + "status": getattr(row, "status", None), | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + | ||
| 51 | +class IdentityAuthService: | ||
| 52 | + def __init__(self, handler: DBHandler) -> None: | ||
| 53 | + self._h = handler | ||
| 54 | + | ||
| 55 | + async def _load_groups(self, user_id: str) -> list[str]: | ||
| 56 | + """用户所属真实组织 id(排除无组织保留行)。""" | ||
| 57 | + rows = await self._h.list_records(_MEMBERSHIP, {"user_id": user_id}, limit=_CAP, offset=0) | ||
| 58 | + gids = [str(getattr(r, "group_id", "")) for r in rows] | ||
| 59 | + return [g for g in gids if g and g != NO_ORG_GROUP_ID] | ||
| 60 | + | ||
| 61 | + async def _issue_for(self, user: Any) -> dict[str, Any]: | ||
| 62 | + user_id = str(getattr(user, "user_id")) | ||
| 63 | + groups = await self._load_groups(user_id) | ||
| 64 | + access, expires_in = issue_access_token( | ||
| 65 | + user_id, | ||
| 66 | + display_name=getattr(user, "display_name", None), | ||
| 67 | + is_admin=bool(getattr(user, "is_admin", False)), | ||
| 68 | + groups=groups, | ||
| 69 | + ) | ||
| 70 | + now = utc_now() | ||
| 71 | + refresh = secrets.token_urlsafe(32) | ||
| 72 | + await self._h.create( | ||
| 73 | + _AUTH_SESSION, | ||
| 74 | + { | ||
| 75 | + "refresh_token": refresh, | ||
| 76 | + "user_id": user_id, | ||
| 77 | + "created_at": now, | ||
| 78 | + "expires_at": now + timedelta(seconds=int(settings.refresh_ttl_seconds)), | ||
| 79 | + }, | ||
| 80 | + ) | ||
| 81 | + return { | ||
| 82 | + "access_token": access, | ||
| 83 | + "token_type": "bearer", | ||
| 84 | + "expires_in": expires_in, | ||
| 85 | + "refresh_token": refresh, | ||
| 86 | + "user": public_user(user), | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + async def login( | ||
| 90 | + self, username: str, password: str, *, provider: str = _DEFAULT_PROVIDER | ||
| 91 | + ) -> dict[str, Any] | str: | ||
| 92 | + """成功返回 token 束 + user;失败返回 ``"bad_credentials"`` / ``"disabled"``。""" | ||
| 93 | + prov = get_provider(provider) | ||
| 94 | + if prov is None: | ||
| 95 | + return "bad_credentials" | ||
| 96 | + user_id = await prov.authenticate(self._h, username.strip(), password) | ||
| 97 | + if not user_id: | ||
| 98 | + _log.info("[Auth] login.fail", username=username.strip(), reason="bad_credentials") | ||
| 99 | + return "bad_credentials" | ||
| 100 | + user = await self._h.get(_APP_USER, {"user_id": user_id}) | ||
| 101 | + if user is None: | ||
| 102 | + return "bad_credentials" | ||
| 103 | + if str(getattr(user, "status", "")) != "active": | ||
| 104 | + return "disabled" | ||
| 105 | + _log.info("[Auth] login.ok", user_id=user_id, provider=provider) | ||
| 106 | + return await self._issue_for(user) | ||
| 107 | + | ||
| 108 | + async def refresh(self, refresh_token: str) -> dict[str, Any] | str: | ||
| 109 | + """用 refresh 换新 access(并轮换 refresh)。失败返回 ``"invalid_refresh"``。""" | ||
| 110 | + if not refresh_token: | ||
| 111 | + return "invalid_refresh" | ||
| 112 | + sess = await self._h.get(_AUTH_SESSION, {"refresh_token": refresh_token}) | ||
| 113 | + if sess is None: | ||
| 114 | + return "invalid_refresh" | ||
| 115 | + expires_at = getattr(sess, "expires_at", None) | ||
| 116 | + if expires_at is not None: | ||
| 117 | + if expires_at.tzinfo is None: | ||
| 118 | + expires_at = expires_at.replace(tzinfo=timezone.utc) | ||
| 119 | + if utc_now() > expires_at: | ||
| 120 | + await self._h.delete(_AUTH_SESSION, {"refresh_token": refresh_token}) | ||
| 121 | + return "invalid_refresh" | ||
| 122 | + user = await self._h.get(_APP_USER, {"user_id": getattr(sess, "user_id", None)}) | ||
| 123 | + if user is None or str(getattr(user, "status", "")) != "active": | ||
| 124 | + await self._h.delete(_AUTH_SESSION, {"refresh_token": refresh_token}) | ||
| 125 | + return "invalid_refresh" | ||
| 126 | + # 轮换:撤销旧 refresh,签发新束 | ||
| 127 | + await self._h.delete(_AUTH_SESSION, {"refresh_token": refresh_token}) | ||
| 128 | + return await self._issue_for(user) | ||
| 129 | + | ||
| 130 | + async def logout(self, refresh_token: str) -> None: | ||
| 131 | + if refresh_token: | ||
| 132 | + await self._h.delete(_AUTH_SESSION, {"refresh_token": refresh_token}) | ||
| 133 | + | ||
| 134 | + async def get_user(self, user_id: str) -> Any | None: | ||
| 135 | + return await self._h.get(_APP_USER, {"user_id": user_id}) | ||
| 136 | + | ||
| 137 | + async def list_my_orgs(self, user_id: str) -> list[dict[str, Any]]: | ||
| 138 | + """自己所属真实组织;仅当不属于任何真实组织时,才附"无组织"入口。""" | ||
| 139 | + gids = await self._load_groups(user_id) | ||
| 140 | + out: list[dict[str, Any]] = [] | ||
| 141 | + for gid in gids: | ||
| 142 | + org = await self._h.get(_ORG, {"group_id": gid}) | ||
| 143 | + if org is not None and str(getattr(org, "status", "")) == "active": | ||
| 144 | + out.append({"group_id": gid, "name": getattr(org, "name", None)}) | ||
| 145 | + if not out: | ||
| 146 | + none_org = await self._h.get(_ORG, {"group_id": NO_ORG_GROUP_ID}) | ||
| 147 | + if none_org is not None: | ||
| 148 | + out.append({"group_id": NO_ORG_GROUP_ID, "name": getattr(none_org, "name", None)}) | ||
| 149 | + return out | ||
| 150 | + | ||
| 151 | + | ||
| 152 | +# ----------------------- 播种 ----------------------- | ||
| 153 | +async def _ensure_org(handler: DBHandler, group_id: str, name: str) -> None: | ||
| 154 | + if await handler.get(_ORG, {"group_id": group_id}) is not None: | ||
| 155 | + return | ||
| 156 | + now = utc_now() | ||
| 157 | + await handler.create( | ||
| 158 | + _ORG, | ||
| 159 | + {"group_id": group_id, "name": name, "status": "active", "created_at": now, "updated_at": now}, | ||
| 160 | + ) | ||
| 161 | + | ||
| 162 | + | ||
| 163 | +async def _ensure_local_user( | ||
| 164 | + handler: DBHandler, *, user_id: str, display_name: str, is_admin: bool, | ||
| 165 | + username: str, password: str, | ||
| 166 | +) -> None: | ||
| 167 | + if await handler.get(_APP_USER, {"user_id": user_id}) is not None: | ||
| 168 | + return | ||
| 169 | + now = utc_now() | ||
| 170 | + await handler.create( | ||
| 171 | + _APP_USER, | ||
| 172 | + {"user_id": user_id, "display_name": display_name, "is_admin": is_admin, | ||
| 173 | + "status": "active", "created_at": now, "updated_at": now}, | ||
| 174 | + ) | ||
| 175 | + await handler.create( | ||
| 176 | + _AUTH_IDENTITY, | ||
| 177 | + {"user_id": user_id, "provider": _DEFAULT_PROVIDER, "external_subject": username, | ||
| 178 | + "credential": hash_password(password), "created_at": now, "updated_at": now}, | ||
| 179 | + ) | ||
| 180 | + _log.info("[auth] seeded user", user_id=user_id, is_admin=is_admin) | ||
| 181 | + | ||
| 182 | + | ||
| 183 | +async def seed_defaults(handler: DBHandler) -> None: | ||
| 184 | + """幂等播种:无组织保留行 + 引导 admin(可选 user1)。不播种任何业务/演示数据。""" | ||
| 185 | + await _ensure_org(handler, NO_ORG_GROUP_ID, "无组织") | ||
| 186 | + if settings.seed_admin: | ||
| 187 | + await _ensure_local_user( | ||
| 188 | + handler, user_id="admin", display_name="Administrator", is_admin=True, | ||
| 189 | + username="admin", password="admin", | ||
| 190 | + ) | ||
| 191 | + if settings.seed_user1: | ||
| 192 | + await _ensure_local_user( | ||
| 193 | + handler, user_id="user1", display_name="User One", is_admin=False, | ||
| 194 | + username="user1", password="user1", | ||
| 195 | + ) | ||
| @@ -0,0 +1,3 @@ | |||
| 1 | +from identity_center.core.iam.services import OrgService, UserService | ||
| 2 | + | ||
| 3 | +__all__ = ("OrgService", "UserService") | ||
| @@ -0,0 +1,301 @@ | |||
| 1 | +"""身份目录管理:组织 / 用户 / 成员 的 CRUD 与批量(权威库=身份库)。 | ||
| 2 | + | ||
| 3 | +bot / 可见性 / 模板等平台配置不在此(留在 claw_manager 管理库)。 | ||
| 4 | +DBHandler.get/list_records 返回记录对象(属性访问),统一用 ``_g`` 读取。 | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +from __future__ import annotations | ||
| 8 | + | ||
| 9 | +from dataclasses import dataclass | ||
| 10 | +from typing import Any | ||
| 11 | + | ||
| 12 | +from openjiuwen_runtime.foundation.db.handler import DBHandler | ||
| 13 | + | ||
| 14 | +from identity_center.core.auth.password import hash_password | ||
| 15 | +from identity_center.infrastructure.logger import get_logger | ||
| 16 | +from identity_center.infrastructure.utils import ( | ||
| 17 | + iso_datetime, | ||
| 18 | + new_uuid4, | ||
| 19 | + strip_optional, | ||
| 20 | + utc_now, | ||
| 21 | +) | ||
| 22 | +from identity_center.models.identity_models import ( | ||
| 23 | + APP_USER_TABLE_DEF, | ||
| 24 | + AUTH_IDENTITY_TABLE_DEF, | ||
| 25 | + AUTH_SESSION_TABLE_DEF, | ||
| 26 | + NO_ORG_GROUP_ID, | ||
| 27 | + ORG_TABLE_DEF, | ||
| 28 | + USER_ORG_MEMBERSHIP_TABLE_DEF, | ||
| 29 | +) | ||
| 30 | + | ||
| 31 | +_log = get_logger(__name__) | ||
| 32 | + | ||
| 33 | +_APP_USER = APP_USER_TABLE_DEF.table_name | ||
| 34 | +_AUTH_IDENTITY = AUTH_IDENTITY_TABLE_DEF.table_name | ||
| 35 | +_AUTH_SESSION = AUTH_SESSION_TABLE_DEF.table_name | ||
| 36 | +_ORG = ORG_TABLE_DEF.table_name | ||
| 37 | +_MEMBERSHIP = USER_ORG_MEMBERSHIP_TABLE_DEF.table_name | ||
| 38 | +_LOCAL = "local" | ||
| 39 | +_CAP = 100_000 | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +def _g(row: Any, key: str, default: Any = None) -> Any: | ||
| 43 | + return getattr(row, key, default) | ||
| 44 | + | ||
| 45 | + | ||
| 46 | +async def _delete_where( | ||
| 47 | + handler: DBHandler, table: str, filters: dict[str, Any], pk_field: str | ||
| 48 | +) -> None: | ||
| 49 | + rows = await handler.list_records(table, filters, limit=_CAP, offset=0) | ||
| 50 | + for r in rows: | ||
| 51 | + await handler.delete(table, {pk_field: _g(r, pk_field)}) | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +@dataclass(frozen=True) | ||
| 55 | +class _PageOpts: | ||
| 56 | + """分页/排序选项(把分页相关参数收进具名对象,避免函数参数过多)。""" | ||
| 57 | + page: int = 1 | ||
| 58 | + page_size: int = 50 | ||
| 59 | + order_by: list[tuple[str, bool]] | None = None | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +async def _paginate( | ||
| 63 | + handler: DBHandler, table: str, filters: dict[str, Any], row_out, opts: _PageOpts, | ||
| 64 | +) -> dict[str, Any]: | ||
| 65 | + page = max(opts.page, 1) | ||
| 66 | + page_size = min(max(opts.page_size, 1), 200) | ||
| 67 | + offset = (page - 1) * page_size | ||
| 68 | + rows = await handler.list_records( | ||
| 69 | + table, filters, limit=page_size, offset=offset, order_by=opts.order_by, | ||
| 70 | + ) | ||
| 71 | + total = await handler.count_records(table, filters) | ||
| 72 | + return {"items": [row_out(r) for r in rows], "total": total, "page": page, "page_size": page_size} | ||
| 73 | + | ||
| 74 | + | ||
| 75 | +# ======================= 组织 ======================= | ||
| 76 | +def _org_out(row: Any) -> dict[str, Any]: | ||
| 77 | + return { | ||
| 78 | + "group_id": _g(row, "group_id"), | ||
| 79 | + "name": _g(row, "name"), | ||
| 80 | + "status": _g(row, "status"), | ||
| 81 | + "created_at": iso_datetime(_g(row, "created_at")), | ||
| 82 | + "updated_at": iso_datetime(_g(row, "updated_at")), | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + | ||
| 86 | +class OrgService: | ||
| 87 | + def __init__(self, handler: DBHandler) -> None: | ||
| 88 | + self._h = handler | ||
| 89 | + | ||
| 90 | + async def list(self, page: int = 1, page_size: int = 50) -> dict[str, Any]: | ||
| 91 | + return await _paginate(self._h, _ORG, {}, _org_out, _PageOpts(page, page_size, [("created_at", False)])) | ||
| 92 | + | ||
| 93 | + async def get(self, group_id: str) -> dict[str, Any] | None: | ||
| 94 | + row = await self._h.get(_ORG, {"group_id": group_id}) | ||
| 95 | + return _org_out(row) if row is not None else None | ||
| 96 | + | ||
| 97 | + async def create(self, group_id: str | None, name: str) -> dict[str, Any]: | ||
| 98 | + gid = strip_optional(group_id) or new_uuid4() | ||
| 99 | + if await self._h.get(_ORG, {"group_id": gid}) is not None: | ||
| 100 | + raise ValueError(f"org already exists: {gid}") | ||
| 101 | + now = utc_now() | ||
| 102 | + await self._h.create( | ||
| 103 | + _ORG, | ||
| 104 | + {"group_id": gid, "name": name.strip(), "status": "active", "created_at": now, "updated_at": now}, | ||
| 105 | + ) | ||
| 106 | + _log.info("[IAM] org.create", group_id=gid, name=name.strip()) | ||
| 107 | + return _org_out(await self._h.get(_ORG, {"group_id": gid})) | ||
| 108 | + | ||
| 109 | + async def update(self, group_id: str, name: str | None, status: str | None) -> dict[str, Any] | None: | ||
| 110 | + if await self._h.get(_ORG, {"group_id": group_id}) is None: | ||
| 111 | + return None | ||
| 112 | + updates: dict[str, Any] = {"updated_at": utc_now()} | ||
| 113 | + if name is not None: | ||
| 114 | + updates["name"] = name.strip() | ||
| 115 | + if status is not None: | ||
| 116 | + updates["status"] = status.strip() | ||
| 117 | + await self._h.update(_ORG, {"group_id": group_id}, updates) | ||
| 118 | + return _org_out(await self._h.get(_ORG, {"group_id": group_id})) | ||
| 119 | + | ||
| 120 | + async def delete(self, group_id: str) -> bool: | ||
| 121 | + if group_id == NO_ORG_GROUP_ID: | ||
| 122 | + raise ValueError("cannot delete the reserved '无组织' org") | ||
| 123 | + if await self._h.get(_ORG, {"group_id": group_id}) is None: | ||
| 124 | + return False | ||
| 125 | + await _delete_where(self._h, _MEMBERSHIP, {"group_id": group_id}, "id") | ||
| 126 | + # bot 可见性(org scope)在管理库,由 claw_manager 侧自行清理(跨库,不在此处理)。 | ||
| 127 | + await self._h.delete(_ORG, {"group_id": group_id}) | ||
| 128 | + _log.info("[IAM] org.delete", group_id=group_id) | ||
| 129 | + return True | ||
| 130 | + | ||
| 131 | + async def list_members(self, group_id: str) -> list[dict[str, Any]]: | ||
| 132 | + """列出某组织的用户;``__none__`` = 无任何组织绑定的用户。""" | ||
| 133 | + if group_id == NO_ORG_GROUP_ID: | ||
| 134 | + users = await self._h.list_records(_APP_USER, {}, limit=_CAP, offset=0) | ||
| 135 | + out: list[dict[str, Any]] = [] | ||
| 136 | + for u in users: | ||
| 137 | + mem = await self._h.list_records(_MEMBERSHIP, {"user_id": _g(u, "user_id")}, limit=1, offset=0) | ||
| 138 | + if not mem: | ||
| 139 | + out.append(_user_out(u)) | ||
| 140 | + return out | ||
| 141 | + rows = await self._h.list_records(_MEMBERSHIP, {"group_id": group_id}, limit=_CAP, offset=0) | ||
| 142 | + members: list[dict[str, Any]] = [] | ||
| 143 | + for r in rows: | ||
| 144 | + u = await self._h.get(_APP_USER, {"user_id": _g(r, "user_id")}) | ||
| 145 | + if u is not None: | ||
| 146 | + members.append(_user_out(u)) | ||
| 147 | + return members | ||
| 148 | + | ||
| 149 | + async def add_members(self, group_id: str, user_ids: list[str]) -> list[str]: | ||
| 150 | + if group_id == NO_ORG_GROUP_ID: | ||
| 151 | + raise ValueError("cannot add members to the no-org group") | ||
| 152 | + if await self._h.get(_ORG, {"group_id": group_id}) is None: | ||
| 153 | + raise ValueError(f"org not found: {group_id}") | ||
| 154 | + added: list[str] = [] | ||
| 155 | + now = utc_now() | ||
| 156 | + for raw in user_ids: | ||
| 157 | + uid = strip_optional(raw) | ||
| 158 | + if not uid: | ||
| 159 | + continue | ||
| 160 | + if await self._h.get(_APP_USER, {"user_id": uid}) is None: | ||
| 161 | + raise ValueError(f"user not found: {uid}") | ||
| 162 | + exists = await self._h.list_records(_MEMBERSHIP, {"user_id": uid, "group_id": group_id}, limit=1, offset=0) | ||
| 163 | + if exists: | ||
| 164 | + continue | ||
| 165 | + await self._h.create(_MEMBERSHIP, {"user_id": uid, "group_id": group_id, "created_at": now}) | ||
| 166 | + added.append(uid) | ||
| 167 | + _log.info("[IAM] org.add_members", group_id=group_id, added=added) | ||
| 168 | + return added | ||
| 169 | + | ||
| 170 | + async def remove_member(self, group_id: str, user_id: str) -> bool: | ||
| 171 | + if group_id == NO_ORG_GROUP_ID: | ||
| 172 | + raise ValueError("cannot remove members from the no-org group") | ||
| 173 | + await _delete_where(self._h, _MEMBERSHIP, {"user_id": user_id, "group_id": group_id}, "id") | ||
| 174 | + _log.info("[IAM] org.remove_member", group_id=group_id, user_id=user_id) | ||
| 175 | + return True | ||
| 176 | + | ||
| 177 | + | ||
| 178 | +# ======================= 用户 ======================= | ||
| 179 | +def _user_out(row: Any) -> dict[str, Any]: | ||
| 180 | + return { | ||
| 181 | + "user_id": _g(row, "user_id"), | ||
| 182 | + "display_name": _g(row, "display_name"), | ||
| 183 | + "is_admin": bool(_g(row, "is_admin")), | ||
| 184 | + "status": _g(row, "status"), | ||
| 185 | + "created_at": iso_datetime(_g(row, "created_at")), | ||
| 186 | + "updated_at": iso_datetime(_g(row, "updated_at")), | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | + | ||
| 190 | +class UserService: | ||
| 191 | + def __init__(self, handler: DBHandler) -> None: | ||
| 192 | + self._h = handler | ||
| 193 | + | ||
| 194 | + async def list(self, page: int = 1, page_size: int = 50) -> dict[str, Any]: | ||
| 195 | + return await _paginate(self._h, _APP_USER, {}, _user_out, _PageOpts(page, page_size, [("created_at", False)])) | ||
| 196 | + | ||
| 197 | + async def get(self, user_id: str) -> dict[str, Any] | None: | ||
| 198 | + row = await self._h.get(_APP_USER, {"user_id": user_id}) | ||
| 199 | + if row is None: | ||
| 200 | + return None | ||
| 201 | + out = _user_out(row) | ||
| 202 | + out["group_ids"] = await self.list_org_ids(user_id) | ||
| 203 | + return out | ||
| 204 | + | ||
| 205 | + async def create( | ||
| 206 | + self, *, user_id: str | None, display_name: str, is_admin: bool, username: str, password: str | ||
| 207 | + ) -> dict[str, Any]: | ||
| 208 | + username = username.strip() | ||
| 209 | + uid = strip_optional(user_id) or username | ||
| 210 | + if await self._h.get(_APP_USER, {"user_id": uid}) is not None: | ||
| 211 | + raise ValueError(f"user already exists: {uid}") | ||
| 212 | + dup = await self._h.list_records( | ||
| 213 | + _AUTH_IDENTITY, {"provider": _LOCAL, "external_subject": username}, limit=1, offset=0, | ||
| 214 | + ) | ||
| 215 | + if dup: | ||
| 216 | + raise ValueError(f"username already taken: {username}") | ||
| 217 | + now = utc_now() | ||
| 218 | + await self._h.create( | ||
| 219 | + _APP_USER, | ||
| 220 | + {"user_id": uid, "display_name": display_name.strip(), "is_admin": is_admin, | ||
| 221 | + "status": "active", "created_at": now, "updated_at": now}, | ||
| 222 | + ) | ||
| 223 | + await self._h.create( | ||
| 224 | + _AUTH_IDENTITY, | ||
| 225 | + {"user_id": uid, "provider": _LOCAL, "external_subject": username, | ||
| 226 | + "credential": hash_password(password), "created_at": now, "updated_at": now}, | ||
| 227 | + ) | ||
| 228 | + _log.info("[IAM] user.create", user_id=uid, username=username, is_admin=is_admin) | ||
| 229 | + created = await self.get(uid) | ||
| 230 | + if created is None: # pragma: no cover - 刚写入必存在 | ||
| 231 | + raise RuntimeError(f"user just created but missing: {uid}") | ||
| 232 | + return created | ||
| 233 | + | ||
| 234 | + async def update( | ||
| 235 | + self, user_id: str, *, display_name: str | None, is_admin: bool | None, | ||
| 236 | + status: str | None, password: str | None, | ||
| 237 | + ) -> dict[str, Any] | None: | ||
| 238 | + if await self._h.get(_APP_USER, {"user_id": user_id}) is None: | ||
| 239 | + return None | ||
| 240 | + now = utc_now() | ||
| 241 | + updates: dict[str, Any] = {"updated_at": now} | ||
| 242 | + if display_name is not None: | ||
| 243 | + updates["display_name"] = display_name.strip() | ||
| 244 | + if is_admin is not None: | ||
| 245 | + updates["is_admin"] = is_admin | ||
| 246 | + if status is not None: | ||
| 247 | + updates["status"] = status.strip() | ||
| 248 | + await self._h.update(_APP_USER, {"user_id": user_id}, updates) | ||
| 249 | + if password: | ||
| 250 | + await self._set_local_password(user_id, password, now) | ||
| 251 | + _log.info("[IAM] user.update", user_id=user_id, | ||
| 252 | + fields=[k for k in updates if k != "updated_at"], password_changed=bool(password)) | ||
| 253 | + return await self.get(user_id) | ||
| 254 | + | ||
| 255 | + async def _set_local_password(self, user_id: str, password: str, now: Any) -> None: | ||
| 256 | + rows = await self._h.list_records(_AUTH_IDENTITY, {"provider": _LOCAL, "user_id": user_id}, limit=1, offset=0) | ||
| 257 | + cred = hash_password(password) | ||
| 258 | + if rows: | ||
| 259 | + await self._h.update(_AUTH_IDENTITY, {"id": _g(rows[0], "id")}, {"credential": cred, "updated_at": now}) | ||
| 260 | + else: | ||
| 261 | + await self._h.create( | ||
| 262 | + _AUTH_IDENTITY, | ||
| 263 | + {"user_id": user_id, "provider": _LOCAL, "external_subject": user_id, | ||
| 264 | + "credential": cred, "created_at": now, "updated_at": now}, | ||
| 265 | + ) | ||
| 266 | + | ||
| 267 | + async def delete(self, user_id: str) -> bool: | ||
| 268 | + if await self._h.get(_APP_USER, {"user_id": user_id}) is None: | ||
| 269 | + return False | ||
| 270 | + await _delete_where(self._h, _AUTH_IDENTITY, {"user_id": user_id}, "id") | ||
| 271 | + await _delete_where(self._h, _AUTH_SESSION, {"user_id": user_id}, "refresh_token") | ||
| 272 | + await _delete_where(self._h, _MEMBERSHIP, {"user_id": user_id}, "id") | ||
| 273 | + await self._h.delete(_APP_USER, {"user_id": user_id}) | ||
| 274 | + _log.info("[IAM] user.delete", user_id=user_id) | ||
| 275 | + return True | ||
| 276 | + | ||
| 277 | + async def list_org_ids(self, user_id: str) -> list[str]: | ||
| 278 | + rows = await self._h.list_records(_MEMBERSHIP, {"user_id": user_id}, limit=_CAP, offset=0) | ||
| 279 | + return [str(_g(r, "group_id")) for r in rows] | ||
| 280 | + | ||
| 281 | + async def set_orgs(self, user_id: str, group_ids: list[str]) -> list[str]: | ||
| 282 | + """整体覆盖该用户的组织绑定(批量)。校验组织存在。""" | ||
| 283 | + if await self._h.get(_APP_USER, {"user_id": user_id}) is None: | ||
| 284 | + raise ValueError(f"user not found: {user_id}") | ||
| 285 | + target: set[str] = set() | ||
| 286 | + for gid in group_ids: | ||
| 287 | + g = strip_optional(gid) | ||
| 288 | + if not g: | ||
| 289 | + continue | ||
| 290 | + if await self._h.get(_ORG, {"group_id": g}) is None: | ||
| 291 | + raise ValueError(f"org not found: {g}") | ||
| 292 | + target.add(g) | ||
| 293 | + current = set(await self.list_org_ids(user_id)) | ||
| 294 | + now = utc_now() | ||
| 295 | + for gid in target - current: | ||
| 296 | + await self._h.create(_MEMBERSHIP, {"user_id": user_id, "group_id": gid, "created_at": now}) | ||
| 297 | + for gid in current - target: | ||
| 298 | + await _delete_where(self._h, _MEMBERSHIP, {"user_id": user_id, "group_id": gid}, "id") | ||
| 299 | + _log.info("[IAM] user.set_orgs", user_id=user_id, | ||
| 300 | + added=sorted(target - current), removed=sorted(current - target)) | ||
| 301 | + return sorted(target) | ||
The file is empty
| @@ -0,0 +1,61 @@ | |||
| 1 | +"""身份服务运行时配置(从 .env / 环境变量加载,前缀 IDENTITY_)。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +from pydantic import Field | ||
| 8 | +from pydantic_settings import BaseSettings, SettingsConfigDict | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def _resolve_env_files() -> tuple[str | Path, ...]: | ||
| 12 | + candidates: list[Path] = [Path.cwd() / ".env"] | ||
| 13 | + here = Path(__file__).resolve() | ||
| 14 | + for depth in (5, 6): | ||
| 15 | + candidates.append(here.parents[depth] / ".env") | ||
| 16 | + return tuple(p for p in candidates if p.is_file()) | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +class Settings(BaseSettings): | ||
| 20 | + model_config = SettingsConfigDict( | ||
| 21 | + env_file=_resolve_env_files() or None, | ||
| 22 | + env_file_encoding="utf-8", | ||
| 23 | + extra="ignore", | ||
| 24 | + ) | ||
| 25 | + | ||
| 26 | + # ---- HTTP ---- | ||
| 27 | + rest_host: str = Field(default="0.0.0.0", validation_alias="IDENTITY_REST_HOST") | ||
| 28 | + rest_port: int = Field(default=8770, validation_alias="IDENTITY_REST_PORT") | ||
| 29 | + | ||
| 30 | + # ---- 数据库(独立身份库;DBHandler 抽象,sqlite/mysql/postgresql 通用)---- | ||
| 31 | + db_type: str = Field(default="sqlite", validation_alias="IDENTITY_DB_TYPE") | ||
| 32 | + sqlite_path: str = Field(default="identity.db", validation_alias="IDENTITY_SQLITE_PATH") | ||
| 33 | + db_host: str = Field(default="127.0.0.1", validation_alias="IDENTITY_DB_HOST") | ||
| 34 | + db_port: int = Field(default=3306, validation_alias="IDENTITY_DB_PORT") | ||
| 35 | + db_user: str = Field(default="root", validation_alias="IDENTITY_DB_USER") | ||
| 36 | + db_password: str = Field(default="root", validation_alias="IDENTITY_DB_PASSWORD") | ||
| 37 | + db_name: str = Field(default="identity", validation_alias="IDENTITY_DB_NAME") | ||
| 38 | + pg_schema: str = Field(default="public", validation_alias="IDENTITY_PG_SCHEMA") | ||
| 39 | + | ||
| 40 | + # ---- JWT(RS256:私钥签发,资源服务器用公钥验签)---- | ||
| 41 | + # 重构:统一去旧名 jiuwenclaw→openjiuwen;跨服务契约,manager_server 须用同一 issuer/audience 验签 | ||
| 42 | + jwt_issuer: str = Field(default="openjiuwen-identity", validation_alias="IDENTITY_JWT_ISSUER") | ||
| 43 | + jwt_audience: str = Field(default="openjiuwen", validation_alias="IDENTITY_JWT_AUDIENCE") | ||
| 44 | + access_ttl_seconds: int = Field(default=1800, validation_alias="IDENTITY_ACCESS_TTL") | ||
| 45 | + refresh_ttl_seconds: int = Field(default=7 * 24 * 3600, validation_alias="IDENTITY_REFRESH_TTL") | ||
| 46 | + # JWT 签名密钥落身份库(表 identity_jwt_signing_key,生成一次→落库→多副本读同一行)。 | ||
| 47 | + | ||
| 48 | + # ---- 引导播种 ---- | ||
| 49 | + seed_admin: bool = Field(default=True, validation_alias="IDENTITY_SEED_ADMIN") | ||
| 50 | + seed_user1: bool = Field(default=True, validation_alias="IDENTITY_SEED_USER1") | ||
| 51 | + | ||
| 52 | + @property | ||
| 53 | + def host(self) -> str: | ||
| 54 | + return self.rest_host | ||
| 55 | + | ||
| 56 | + @property | ||
| 57 | + def port(self) -> int: | ||
| 58 | + return self.rest_port | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +settings = Settings() | ||
| @@ -0,0 +1,85 @@ | |||
| 1 | +"""身份服务数据库句柄(SQLite / MySQL / PostgreSQL);配置来自 .env(IDENTITY_*)。 | ||
| 2 | + | ||
| 3 | +独立身份库:与 claw_manager 的管理库物理隔离。底层走 foundation DBHandler 抽象, | ||
| 4 | +先用 SQLite 保证功能,后续切 MySQL/PostgreSQL 仅改 .env,不动业务代码。 | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +from __future__ import annotations | ||
| 8 | + | ||
| 9 | +from pathlib import Path | ||
| 10 | +from typing import Any | ||
| 11 | + | ||
| 12 | +from openjiuwen_runtime.foundation.db.handler import DBHandler | ||
| 13 | +from openjiuwen_runtime.foundation.db.mysql_handler import MySQLHandler | ||
| 14 | +from openjiuwen_runtime.foundation.db.postgresql_handler import PostgreSQLHandler | ||
| 15 | +from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler | ||
| 16 | + | ||
| 17 | +from identity_center.infrastructure.config import Settings, settings | ||
| 18 | +from identity_center.infrastructure.logger import get_logger | ||
| 19 | + | ||
| 20 | +_PKG_ROOT = Path(__file__).resolve().parents[3] | ||
| 21 | + | ||
| 22 | +logger = get_logger(__name__) | ||
| 23 | + | ||
| 24 | +_db_handler: DBHandler | None = None | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +def get_db_handler() -> DBHandler: | ||
| 28 | + if _db_handler is None: | ||
| 29 | + raise RuntimeError("Database handler is not initialized; call create_db_handler first.") | ||
| 30 | + return _db_handler | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def _sqlite_handler_from_settings(cfg: Settings) -> SQLiteHandler: | ||
| 34 | + raw_path = Path(cfg.sqlite_path.strip()).expanduser() | ||
| 35 | + raw_path = raw_path.resolve() if raw_path.is_absolute() else (_PKG_ROOT / raw_path).resolve() | ||
| 36 | + raw_path.parent.mkdir(parents=True, exist_ok=True) | ||
| 37 | + return SQLiteHandler(str(raw_path.as_posix())) | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +def _mysql_handler_from_settings(cfg: Settings) -> MySQLHandler: | ||
| 41 | + try: | ||
| 42 | + return MySQLHandler( | ||
| 43 | + host=str(cfg.db_host).strip(), port=int(cfg.db_port), | ||
| 44 | + user=str(cfg.db_user).strip(), password=str(cfg.db_password), | ||
| 45 | + database=str(cfg.db_name).strip(), | ||
| 46 | + ) | ||
| 47 | + except (TypeError, ValueError) as e: | ||
| 48 | + logger.exception("Invalid MySQL config (IDENTITY_DB_*).") | ||
| 49 | + raise ValueError("Invalid MySQL database configuration.") from e | ||
| 50 | + | ||
| 51 | + | ||
| 52 | +def _pg_handler_from_settings(cfg: Settings) -> PostgreSQLHandler: | ||
| 53 | + try: | ||
| 54 | + return PostgreSQLHandler( | ||
| 55 | + host=str(cfg.db_host).strip(), port=int(cfg.db_port), | ||
| 56 | + user=str(cfg.db_user).strip(), password=str(cfg.db_password), | ||
| 57 | + database=str(cfg.db_name).strip(), schema=str(cfg.pg_schema).strip(), | ||
| 58 | + ) | ||
| 59 | + except (TypeError, ValueError) as e: | ||
| 60 | + logger.exception("Invalid PostgreSQL config (IDENTITY_DB_*).") | ||
| 61 | + raise ValueError("Invalid PostgreSQL database configuration.") from e | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +def create_db_handler(cfg: Settings | None = None) -> DBHandler: | ||
| 65 | + global _db_handler | ||
| 66 | + active = cfg or settings | ||
| 67 | + db_type = str(active.db_type or "").strip().lower() or "sqlite" | ||
| 68 | + logger.info("Using identity database: %s", db_type) | ||
| 69 | + if db_type == "sqlite": | ||
| 70 | + _db_handler = _sqlite_handler_from_settings(active) | ||
| 71 | + elif db_type == "mysql": | ||
| 72 | + _db_handler = _mysql_handler_from_settings(active) | ||
| 73 | + elif db_type in ("postgresql", "postgres", "pg"): | ||
| 74 | + _db_handler = _pg_handler_from_settings(active) | ||
| 75 | + else: | ||
| 76 | + raise ValueError(f"Unsupported db_type: {db_type}. Use sqlite/mysql/postgresql.") | ||
| 77 | + return _db_handler | ||
| 78 | + | ||
| 79 | + | ||
| 80 | +def database_config_summary(cfg: Settings | None = None) -> dict[str, Any]: | ||
| 81 | + active = cfg or settings | ||
| 82 | + db_type = str(active.db_type or "").strip().lower() or "sqlite" | ||
| 83 | + if db_type == "sqlite": | ||
| 84 | + return {"db_type": db_type, "sqlite_path": active.sqlite_path} | ||
| 85 | + return {"db_type": db_type, "host": active.db_host, "port": active.db_port, "database": active.db_name} | ||
| @@ -0,0 +1,24 @@ | |||
| 1 | +"""结构化日志(与 claw_manager / 网关侧风格一致,便于关联)。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import sys | ||
| 6 | + | ||
| 7 | +import structlog | ||
| 8 | + | ||
| 9 | + | ||
| 10 | +def configure_logging() -> None: | ||
| 11 | + structlog.configure( | ||
| 12 | + processors=[ | ||
| 13 | + structlog.processors.add_log_level, | ||
| 14 | + structlog.processors.TimeStamper(fmt="iso", utc=True), | ||
| 15 | + structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty()), | ||
| 16 | + ], | ||
| 17 | + context_class=dict, | ||
| 18 | + logger_factory=structlog.PrintLoggerFactory(file=sys.stderr), | ||
| 19 | + cache_logger_on_first_use=True, | ||
| 20 | + ) | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def get_logger(name: str): | ||
| 24 | + return structlog.get_logger(name) | ||
| @@ -0,0 +1,34 @@ | |||
| 1 | +"""通用工具:UTC 时间、ID、字符串处理。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import uuid | ||
| 6 | +from datetime import datetime, timezone | ||
| 7 | + | ||
| 8 | + | ||
| 9 | +def utc_now() -> datetime: | ||
| 10 | + return datetime.now(timezone.utc) | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def iso_datetime(dt: datetime | None) -> str | None: | ||
| 14 | + """datetime → ISO-8601 UTC 字符串(末尾 Z);None 返回 None。""" | ||
| 15 | + if dt is None: | ||
| 16 | + return None | ||
| 17 | + if dt.tzinfo is None: | ||
| 18 | + dt = dt.replace(tzinfo=timezone.utc) | ||
| 19 | + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +def new_id() -> str: | ||
| 23 | + return str(uuid.uuid4()) | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +def new_uuid4() -> str: | ||
| 27 | + return str(uuid.uuid4()) | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +def strip_optional(value: str | None) -> str | None: | ||
| 31 | + if value is None: | ||
| 32 | + return None | ||
| 33 | + stripped = value.strip() | ||
| 34 | + return stripped or None | ||
| @@ -0,0 +1,21 @@ | |||
| 1 | +"""进程入口:等价于 `uvicorn identity_center.app:app`。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | + | ||
| 6 | +def main() -> None: | ||
| 7 | + import uvicorn | ||
| 8 | + | ||
| 9 | + from identity_center.infrastructure.config import settings | ||
| 10 | + | ||
| 11 | + uvicorn.run( | ||
| 12 | + "identity_center.app:app", | ||
| 13 | + host=settings.host, | ||
| 14 | + port=settings.port, | ||
| 15 | + factory=False, | ||
| 16 | + log_level="info", | ||
| 17 | + ) | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +if __name__ == "__main__": | ||
| 21 | + main() | ||
The file is empty
| @@ -0,0 +1,113 @@ | |||
| 1 | +"""身份服务表定义:用户 / 认证身份 / 刷新会话 / 组织 / 成员。 | ||
| 2 | + | ||
| 3 | +权威的"人 + 凭据 + 目录(组织/成员)"数据源,独立于 claw_manager 管理库。 | ||
| 4 | +认证与身份解耦:``app_user`` 存身份/角色,``auth_identity`` 存凭据/外部 IdP | ||
| 5 | +(二次开发新增 provider 不动业务表)。bot / 可见性 / 模板等平台配置留在管理库。 | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +from __future__ import annotations | ||
| 9 | + | ||
| 10 | +from openjiuwen_runtime.foundation.db.table_def import ( | ||
| 11 | + ColumnDefinition, | ||
| 12 | + IndexDefinition, | ||
| 13 | + TableDefinition, | ||
| 14 | +) | ||
| 15 | + | ||
| 16 | +# 无组织的保留 group_id(避免 NULL 特判,可见性/查询全程统一)。 | ||
| 17 | +NO_ORG_GROUP_ID = "__none__" | ||
| 18 | + | ||
| 19 | +APP_USER_TABLE_DEF = TableDefinition( | ||
| 20 | + table_name="app_user", | ||
| 21 | + columns=[ | ||
| 22 | + ColumnDefinition("user_id", "string", length=64, primary_key=True, nullable=False), | ||
| 23 | + ColumnDefinition("display_name", "string", length=128, nullable=False), | ||
| 24 | + ColumnDefinition("is_admin", "boolean", nullable=False, default=False), | ||
| 25 | + ColumnDefinition("status", "string", length=16, nullable=False, default="active"), | ||
| 26 | + ColumnDefinition("created_at", "datetime", nullable=False), | ||
| 27 | + ColumnDefinition("updated_at", "datetime", nullable=False), | ||
| 28 | + ], | ||
| 29 | +) | ||
| 30 | + | ||
| 31 | +# 认证身份:一个 user 可挂多种登录方式(local / oidc / ldap...)。换登录方式只动这张表。 | ||
| 32 | +AUTH_IDENTITY_TABLE_DEF = TableDefinition( | ||
| 33 | + table_name="auth_identity", | ||
| 34 | + columns=[ | ||
| 35 | + ColumnDefinition("id", "integer", primary_key=True, autoincrement=True, nullable=False), | ||
| 36 | + ColumnDefinition("user_id", "string", length=64, nullable=False), | ||
| 37 | + ColumnDefinition("provider", "string", length=32, nullable=False), | ||
| 38 | + ColumnDefinition("external_subject", "string", length=256, nullable=False), | ||
| 39 | + ColumnDefinition("credential", "string", length=512, nullable=True), | ||
| 40 | + ColumnDefinition("created_at", "datetime", nullable=False), | ||
| 41 | + ColumnDefinition("updated_at", "datetime", nullable=False), | ||
| 42 | + ], | ||
| 43 | + indexes=[ | ||
| 44 | + IndexDefinition(["provider", "external_subject"], unique=True), | ||
| 45 | + IndexDefinition(["user_id"], unique=False), | ||
| 46 | + ], | ||
| 47 | +) | ||
| 48 | + | ||
| 49 | +# 刷新会话:refresh token 落地,可撤销/轮换(access JWT 自包含、不落库)。 | ||
| 50 | +AUTH_SESSION_TABLE_DEF = TableDefinition( | ||
| 51 | + table_name="auth_session", | ||
| 52 | + columns=[ | ||
| 53 | + ColumnDefinition("refresh_token", "string", length=128, primary_key=True, nullable=False), | ||
| 54 | + ColumnDefinition("user_id", "string", length=64, nullable=False), | ||
| 55 | + ColumnDefinition("created_at", "datetime", nullable=False), | ||
| 56 | + ColumnDefinition("expires_at", "datetime", nullable=False), | ||
| 57 | + ], | ||
| 58 | + indexes=[ | ||
| 59 | + IndexDefinition(["user_id"], unique=False), | ||
| 60 | + IndexDefinition(["expires_at"], unique=False), | ||
| 61 | + ], | ||
| 62 | +) | ||
| 63 | + | ||
| 64 | +ORG_TABLE_DEF = TableDefinition( | ||
| 65 | + table_name="org", | ||
| 66 | + columns=[ | ||
| 67 | + ColumnDefinition("group_id", "string", length=64, primary_key=True, nullable=False), | ||
| 68 | + ColumnDefinition("name", "string", length=128, nullable=False), | ||
| 69 | + ColumnDefinition("status", "string", length=16, nullable=False, default="active"), | ||
| 70 | + ColumnDefinition("created_at", "datetime", nullable=False), | ||
| 71 | + ColumnDefinition("updated_at", "datetime", nullable=False), | ||
| 72 | + ], | ||
| 73 | +) | ||
| 74 | + | ||
| 75 | +# 用户↔组织 多对多。默认无组织 = 不存在任何真实成员关系(自动归类)。 | ||
| 76 | +USER_ORG_MEMBERSHIP_TABLE_DEF = TableDefinition( | ||
| 77 | + table_name="user_org_membership", | ||
| 78 | + columns=[ | ||
| 79 | + ColumnDefinition("id", "integer", primary_key=True, autoincrement=True, nullable=False), | ||
| 80 | + ColumnDefinition("user_id", "string", length=64, nullable=False), | ||
| 81 | + ColumnDefinition("group_id", "string", length=64, nullable=False), | ||
| 82 | + ColumnDefinition("created_at", "datetime", nullable=False), | ||
| 83 | + ], | ||
| 84 | + indexes=[ | ||
| 85 | + IndexDefinition(["user_id", "group_id"], unique=True), | ||
| 86 | + IndexDefinition(["group_id"], unique=False), | ||
| 87 | + ], | ||
| 88 | +) | ||
| 89 | + | ||
| 90 | +# JWT 签名密钥(RS256,单例固定主键 id="default")。生成一次→落库→所有副本读同一行。 | ||
| 91 | +# 参考 claw_manager `manager_identity` 的落库范式,但本表/库/算法独立(RSA PEM 文本)。 | ||
| 92 | +IDENTITY_JWT_SIGNING_KEY_TABLE_DEF = TableDefinition( | ||
| 93 | + table_name="identity_jwt_signing_key", | ||
| 94 | + columns=[ | ||
| 95 | + ColumnDefinition("id", "string", length=32, primary_key=True, nullable=False), | ||
| 96 | + ColumnDefinition("sign_alg", "string", length=32, nullable=False), # "RS256" | ||
| 97 | + ColumnDefinition("private_key", "string", length=4096, nullable=False), # PKCS8 PEM | ||
| 98 | + ColumnDefinition("public_key", "string", length=1024, nullable=False), # SPKI PEM | ||
| 99 | + ColumnDefinition("key_version", "string", length=32, nullable=False), # "v1" | ||
| 100 | + ColumnDefinition("fingerprint", "string", length=128, nullable=False), # SHA-256 hex(public PEM) | ||
| 101 | + ColumnDefinition("created_at", "datetime", nullable=False), | ||
| 102 | + ColumnDefinition("updated_at", "datetime", nullable=False), | ||
| 103 | + ], | ||
| 104 | +) | ||
| 105 | + | ||
| 106 | +IDENTITY_TABLE_DEFINITIONS = ( | ||
| 107 | + APP_USER_TABLE_DEF, | ||
| 108 | + AUTH_IDENTITY_TABLE_DEF, | ||
| 109 | + AUTH_SESSION_TABLE_DEF, | ||
| 110 | + ORG_TABLE_DEF, | ||
| 111 | + USER_ORG_MEMBERSHIP_TABLE_DEF, | ||
| 112 | + IDENTITY_JWT_SIGNING_KEY_TABLE_DEF, | ||
| 113 | +) | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | +"""身份库表初始化(幂等)。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from openjiuwen_runtime.foundation.db.handler import DBHandler | ||
| 6 | + | ||
| 7 | +from identity_center.models.identity_models import IDENTITY_TABLE_DEFINITIONS | ||
| 8 | + | ||
| 9 | + | ||
| 10 | +async def init_all_tables(handler: DBHandler) -> None: | ||
| 11 | + for table_def in IDENTITY_TABLE_DEFINITIONS: | ||
| 12 | + await handler.init_table(table_def) | ||
The file is empty
| @@ -0,0 +1,97 @@ | |||
| 1 | +"""认证路由:OAuth2 密码流 /token、/me、/me/orgs、/refresh、/logout、/public_key。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from typing import Annotated, Any | ||
| 6 | + | ||
| 7 | +from fastapi import APIRouter, Depends, HTTPException, Response, status | ||
| 8 | +from fastapi.security import OAuth2PasswordRequestForm | ||
| 9 | +from openjiuwen_runtime.foundation.db.handler import DBHandler | ||
| 10 | + | ||
| 11 | +from identity_center.core.auth import IdentityAuthService | ||
| 12 | +from identity_center.infrastructure.db import get_db_handler | ||
| 13 | +from identity_center.routers.deps import get_current_claims | ||
| 14 | +from identity_center.schemas.auth_schemas import ( | ||
| 15 | + LogoutBody, | ||
| 16 | + RefreshBody, | ||
| 17 | + TokenResponse, | ||
| 18 | + UserOut, | ||
| 19 | +) | ||
| 20 | +from identity_center.security.jwt_keys import public_pem | ||
| 21 | + | ||
| 22 | +_Handler = Annotated[DBHandler, Depends(get_db_handler)] | ||
| 23 | +_Claims = Annotated[dict[str, Any], Depends(get_current_claims)] | ||
| 24 | + | ||
| 25 | +auth_router = APIRouter() | ||
| 26 | + | ||
| 27 | +# detail 用稳定 code(前端据此映射双语文案,未知回退通用句子)。 | ||
| 28 | +_LOGIN_ERR = { | ||
| 29 | + "bad_credentials": (status.HTTP_401_UNAUTHORIZED, "auth_bad_credentials"), | ||
| 30 | + "disabled": (status.HTTP_403_FORBIDDEN, "auth_disabled"), | ||
| 31 | + "invalid_refresh": (status.HTTP_401_UNAUTHORIZED, "auth_invalid_refresh"), | ||
| 32 | +} | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +def _raise(code: str) -> None: | ||
| 36 | + http_status, detail = _LOGIN_ERR.get(code, (status.HTTP_400_BAD_REQUEST, code)) | ||
| 37 | + raise HTTPException( | ||
| 38 | + status_code=http_status, detail=detail, headers={"WWW-Authenticate": "Bearer"} | ||
| 39 | + ) | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +@auth_router.post("/token", response_model=TokenResponse) | ||
| 43 | +async def token( | ||
| 44 | + form: Annotated[OAuth2PasswordRequestForm, Depends()], | ||
| 45 | + handler: _Handler, | ||
| 46 | +): | ||
| 47 | + """OAuth2 密码流:表单 username/password → 签发 access JWT + refresh。 | ||
| 48 | + | ||
| 49 | + (Swagger 右上角 Authorize 即调用此端点。) | ||
| 50 | + """ | ||
| 51 | + result = await IdentityAuthService(handler).login(form.username, form.password) | ||
| 52 | + if isinstance(result, str): | ||
| 53 | + _raise(result) | ||
| 54 | + return TokenResponse(**{k: result[k] for k in ("access_token", "token_type", "expires_in", "refresh_token")}) | ||
| 55 | + | ||
| 56 | + | ||
| 57 | +@auth_router.get("/me", response_model=UserOut) | ||
| 58 | +async def me(claims: _Claims, handler: _Handler): | ||
| 59 | + """当前登录用户(来自 JWT claims,并以库中最新状态校正)。""" | ||
| 60 | + svc = IdentityAuthService(handler) | ||
| 61 | + user = await svc.get_user(str(claims.get("sub"))) | ||
| 62 | + if user is None or str(getattr(user, "status", "")) != "active": | ||
| 63 | + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found or disabled") | ||
| 64 | + return UserOut( | ||
| 65 | + user_id=str(getattr(user, "user_id")), | ||
| 66 | + display_name=getattr(user, "display_name", None), | ||
| 67 | + is_admin=bool(getattr(user, "is_admin", False)), | ||
| 68 | + status=getattr(user, "status", None), | ||
| 69 | + groups=list(claims.get("groups") or []), | ||
| 70 | + ) | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +@auth_router.get("/me/orgs") | ||
| 74 | +async def my_orgs(claims: _Claims, handler: _Handler): | ||
| 75 | + """当前用户可选组织(有真实组织则不含『无组织』)。""" | ||
| 76 | + orgs = await IdentityAuthService(handler).list_my_orgs(str(claims.get("sub"))) | ||
| 77 | + return {"orgs": orgs} | ||
| 78 | + | ||
| 79 | + | ||
| 80 | +@auth_router.post("/refresh", response_model=TokenResponse) | ||
| 81 | +async def refresh(body: RefreshBody, handler: _Handler): | ||
| 82 | + result = await IdentityAuthService(handler).refresh(body.refresh_token) | ||
| 83 | + if isinstance(result, str): | ||
| 84 | + _raise(result) | ||
| 85 | + return TokenResponse(**{k: result[k] for k in ("access_token", "token_type", "expires_in", "refresh_token")}) | ||
| 86 | + | ||
| 87 | + | ||
| 88 | +@auth_router.post("/logout") | ||
| 89 | +async def logout(body: LogoutBody, handler: _Handler): | ||
| 90 | + await IdentityAuthService(handler).logout(body.refresh_token) | ||
| 91 | + return {"ok": True} | ||
| 92 | + | ||
| 93 | + | ||
| 94 | +@auth_router.get("/public_key") | ||
| 95 | +async def public_key(): | ||
| 96 | + """RS256 公钥(PEM)。资源服务器拿它本地验签 JWT,无需回调本服务。""" | ||
| 97 | + return Response(content=public_pem(), media_type="application/x-pem-file") | ||
| @@ -0,0 +1,40 @@ | |||
| 1 | +"""鉴权依赖:OAuth2 Bearer + JWT 验签 → 当前用户 claims;admin 守卫。 | ||
| 2 | + | ||
| 3 | +``oauth2_scheme`` 让 ``/docs`` 出现 Authorize(密码流,tokenUrl 指向 /token)。 | ||
| 4 | +本服务自身验签用本地公钥;claw_manager / 企业版 web 复用同样的验签逻辑(拿公钥)。 | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +from __future__ import annotations | ||
| 8 | + | ||
| 9 | +from typing import Annotated, Any | ||
| 10 | + | ||
| 11 | +import jwt | ||
| 12 | +from fastapi import Depends, HTTPException, status | ||
| 13 | +from fastapi.security import OAuth2PasswordBearer | ||
| 14 | + | ||
| 15 | +from identity_center.security.tokens import decode_access_token | ||
| 16 | + | ||
| 17 | +# tokenUrl 为相对路径,指向下方 /v1/auth/token;Swagger Authorize 用它走密码流。 | ||
| 18 | +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="v1/auth/token") | ||
| 19 | + | ||
| 20 | +_CRED_EXC = HTTPException( | ||
| 21 | + status_code=status.HTTP_401_UNAUTHORIZED, | ||
| 22 | + detail="invalid or expired token", | ||
| 23 | + headers={"WWW-Authenticate": "Bearer"}, | ||
| 24 | +) | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +async def get_current_claims(token: Annotated[str, Depends(oauth2_scheme)]) -> dict[str, Any]: | ||
| 28 | + try: | ||
| 29 | + return decode_access_token(token) | ||
| 30 | + except jwt.PyJWTError as exc: | ||
| 31 | + raise _CRED_EXC from exc | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +_Claims = Annotated[dict[str, Any], Depends(get_current_claims)] | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +async def require_admin(claims: _Claims) -> dict[str, Any]: | ||
| 38 | + if not bool(claims.get("is_admin")): | ||
| 39 | + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin required") | ||
| 40 | + return claims | ||
| @@ -0,0 +1,205 @@ | |||
| 1 | +"""身份目录管理 API:组织 / 用户 / 成员(全部 admin 守卫,校验 JWT 的 is_admin claim)。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from typing import Annotated, Any | ||
| 6 | + | ||
| 7 | +from fastapi import APIRouter, Depends, HTTPException, Query | ||
| 8 | +from openjiuwen_runtime.foundation.db.handler import DBHandler | ||
| 9 | + | ||
| 10 | +from identity_center.core.iam import OrgService, UserService | ||
| 11 | +from identity_center.infrastructure.db import get_db_handler | ||
| 12 | +from identity_center.routers.deps import require_admin | ||
| 13 | +from identity_center.schemas.iam_schemas import ( | ||
| 14 | + AddMembersBody, | ||
| 15 | + OrgCreateBody, | ||
| 16 | + OrgUpdateBody, | ||
| 17 | + SetMembershipBody, | ||
| 18 | + UserCreateBody, | ||
| 19 | + UsersBatchCreateBody, | ||
| 20 | + UserUpdateBody, | ||
| 21 | +) | ||
| 22 | + | ||
| 23 | +_Handler = Annotated[DBHandler, Depends(get_db_handler)] | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +def _bad(exc: ValueError) -> HTTPException: | ||
| 27 | + return HTTPException(status_code=400, detail=str(exc)) | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +# ============== 组织(admin) ============== | ||
| 31 | +org_router = APIRouter(dependencies=[Depends(require_admin)]) | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +@org_router.get("/") | ||
| 35 | +async def list_orgs(handler: _Handler, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200)): | ||
| 36 | + return await OrgService(handler).list(page, page_size) | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +@org_router.post("/") | ||
| 40 | +async def create_org(body: OrgCreateBody, handler: _Handler): | ||
| 41 | + try: | ||
| 42 | + return await OrgService(handler).create(body.group_id, body.name) | ||
| 43 | + except ValueError as e: | ||
| 44 | + raise _bad(e) from e | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +@org_router.get("/{group_id}") | ||
| 48 | +async def get_org(group_id: str, handler: _Handler): | ||
| 49 | + org = await OrgService(handler).get(group_id) | ||
| 50 | + if org is None: | ||
| 51 | + raise HTTPException(status_code=404, detail="org not found") | ||
| 52 | + return org | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +@org_router.patch("/{group_id}") | ||
| 56 | +async def update_org(group_id: str, body: OrgUpdateBody, handler: _Handler): | ||
| 57 | + org = await OrgService(handler).update(group_id, body.name, body.status) | ||
| 58 | + if org is None: | ||
| 59 | + raise HTTPException(status_code=404, detail="org not found") | ||
| 60 | + return org | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +@org_router.get("/{group_id}/members") | ||
| 64 | +async def list_org_members(group_id: str, handler: _Handler): | ||
| 65 | + return {"users": await OrgService(handler).list_members(group_id)} | ||
| 66 | + | ||
| 67 | + | ||
| 68 | +@org_router.post("/{group_id}/members") | ||
| 69 | +async def add_org_members(group_id: str, body: AddMembersBody, handler: _Handler): | ||
| 70 | + try: | ||
| 71 | + return {"added": await OrgService(handler).add_members(group_id, body.user_ids)} | ||
| 72 | + except ValueError as e: | ||
| 73 | + raise _bad(e) from e | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +@org_router.delete("/{group_id}/members/{user_id}") | ||
| 77 | +async def remove_org_member(group_id: str, user_id: str, handler: _Handler): | ||
| 78 | + try: | ||
| 79 | + await OrgService(handler).remove_member(group_id, user_id) | ||
| 80 | + except ValueError as e: | ||
| 81 | + raise _bad(e) from e | ||
| 82 | + return {"removed": True} | ||
| 83 | + | ||
| 84 | + | ||
| 85 | +@org_router.delete("/{group_id}") | ||
| 86 | +async def delete_org(group_id: str, handler: _Handler): | ||
| 87 | + try: | ||
| 88 | + ok = await OrgService(handler).delete(group_id) | ||
| 89 | + except ValueError as e: | ||
| 90 | + raise _bad(e) from e | ||
| 91 | + if not ok: | ||
| 92 | + raise HTTPException(status_code=404, detail="org not found") | ||
| 93 | + return {"deleted": True} | ||
| 94 | + | ||
| 95 | + | ||
| 96 | +# ============== 用户(admin) ============== | ||
| 97 | +user_router = APIRouter(dependencies=[Depends(require_admin)]) | ||
| 98 | + | ||
| 99 | + | ||
| 100 | +@user_router.get("/") | ||
| 101 | +async def list_users(handler: _Handler, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200)): | ||
| 102 | + return await UserService(handler).list(page, page_size) | ||
| 103 | + | ||
| 104 | + | ||
| 105 | +@user_router.post("/") | ||
| 106 | +async def create_user(body: UserCreateBody, handler: _Handler): | ||
| 107 | + try: | ||
| 108 | + return await UserService(handler).create( | ||
| 109 | + user_id=body.user_id, display_name=body.display_name, is_admin=body.is_admin, | ||
| 110 | + username=body.username, password=body.password, | ||
| 111 | + ) | ||
| 112 | + except ValueError as e: | ||
| 113 | + raise _bad(e) from e | ||
| 114 | + | ||
| 115 | + | ||
| 116 | +@user_router.post("/batch") | ||
| 117 | +async def batch_create_users(body: UsersBatchCreateBody, handler: _Handler): | ||
| 118 | + """批量新建用户(前端解析 Excel/CSV 后提交 JSON)。 | ||
| 119 | + | ||
| 120 | + 逐行处理、部分成功:建用户失败(重名/缺项)只标记该行 error;组织无效或设置报错 | ||
| 121 | + 仅忽略该组织并归入无组织(告警),绝不使建用户失败。 | ||
| 122 | + """ | ||
| 123 | + usvc = UserService(handler) | ||
| 124 | + # 一次性载入有效组织,内存校验(id 与名称都可匹配) | ||
| 125 | + orgs_page = await OrgService(handler).list(1, 200) | ||
| 126 | + orgs = orgs_page.get("items", []) if isinstance(orgs_page, dict) else [] | ||
| 127 | + valid_ids = {org["group_id"] for org in orgs} | ||
| 128 | + name_to_id = {org["name"]: org["group_id"] for org in orgs if org.get("name")} | ||
| 129 | + | ||
| 130 | + results: list[dict[str, Any]] = [] | ||
| 131 | + ok_count = 0 | ||
| 132 | + for idx, item in enumerate(body.users): | ||
| 133 | + row = idx + 1 | ||
| 134 | + try: | ||
| 135 | + created = await usvc.create( | ||
| 136 | + user_id=None, display_name=(item.display_name or item.username).strip(), | ||
| 137 | + is_admin=item.is_admin, username=item.username, password=item.password, | ||
| 138 | + ) | ||
| 139 | + except ValueError as exc: | ||
| 140 | + results.append({"row": row, "username": item.username, "ok": False, "error": str(exc)}) | ||
| 141 | + continue | ||
| 142 | + uid = created["user_id"] | ||
| 143 | + warnings: list[str] = [] | ||
| 144 | + resolved: list[str] = [] | ||
| 145 | + for token in item.orgs: | ||
| 146 | + tok = str(token).strip() | ||
| 147 | + if not tok: | ||
| 148 | + continue | ||
| 149 | + gid = tok if tok in valid_ids else name_to_id.get(tok) | ||
| 150 | + if gid: | ||
| 151 | + resolved.append(gid) | ||
| 152 | + else: | ||
| 153 | + warnings.append(f"组织 '{tok}' 不存在,已忽略") | ||
| 154 | + if resolved: | ||
| 155 | + try: | ||
| 156 | + await usvc.set_orgs(uid, resolved) | ||
| 157 | + except ValueError as exc: # 极端情况:仍归入无组织,不失败 | ||
| 158 | + warnings.append(f"设置组织失败,已归入无组织:{exc}") | ||
| 159 | + ok_count += 1 | ||
| 160 | + results.append({"row": row, "username": item.username, "ok": True, "user_id": uid, "warnings": warnings}) | ||
| 161 | + | ||
| 162 | + return { | ||
| 163 | + "summary": {"total": len(body.users), "ok": ok_count, "failed": len(body.users) - ok_count}, | ||
| 164 | + "results": results, | ||
| 165 | + } | ||
| 166 | + | ||
| 167 | + | ||
| 168 | +@user_router.get("/{user_id}") | ||
| 169 | +async def get_user(user_id: str, handler: _Handler): | ||
| 170 | + user = await UserService(handler).get(user_id) | ||
| 171 | + if user is None: | ||
| 172 | + raise HTTPException(status_code=404, detail="user not found") | ||
| 173 | + return user | ||
| 174 | + | ||
| 175 | + | ||
| 176 | +@user_router.patch("/{user_id}") | ||
| 177 | +async def update_user(user_id: str, body: UserUpdateBody, handler: _Handler): | ||
| 178 | + user = await UserService(handler).update( | ||
| 179 | + user_id, display_name=body.display_name, is_admin=body.is_admin, | ||
| 180 | + status=body.status, password=body.password, | ||
| 181 | + ) | ||
| 182 | + if user is None: | ||
| 183 | + raise HTTPException(status_code=404, detail="user not found") | ||
| 184 | + return user | ||
| 185 | + | ||
| 186 | + | ||
| 187 | +@user_router.delete("/{user_id}") | ||
| 188 | +async def delete_user(user_id: str, handler: _Handler): | ||
| 189 | + ok = await UserService(handler).delete(user_id) | ||
| 190 | + if not ok: | ||
| 191 | + raise HTTPException(status_code=404, detail="user not found") | ||
| 192 | + return {"deleted": True} | ||
| 193 | + | ||
| 194 | + | ||
| 195 | +@user_router.get("/{user_id}/orgs") | ||
| 196 | +async def list_user_orgs(user_id: str, handler: _Handler): | ||
| 197 | + return {"group_ids": await UserService(handler).list_org_ids(user_id)} | ||
| 198 | + | ||
| 199 | + | ||
| 200 | +@user_router.put("/{user_id}/orgs") | ||
| 201 | +async def set_user_orgs(user_id: str, body: SetMembershipBody, handler: _Handler): | ||
| 202 | + try: | ||
| 203 | + return {"group_ids": await UserService(handler).set_orgs(user_id, body.group_ids)} | ||
| 204 | + except ValueError as e: | ||
| 205 | + raise _bad(e) from e | ||
| @@ -0,0 +1,16 @@ | |||
| 1 | +"""路由注册:全部挂在 /v1 下。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from fastapi import APIRouter, FastAPI | ||
| 6 | + | ||
| 7 | +from identity_center.routers.auth_routers import auth_router | ||
| 8 | +from identity_center.routers.iam_routers import org_router, user_router | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def router_register(app: FastAPI) -> None: | ||
| 12 | + v1 = APIRouter(prefix="/v1") | ||
| 13 | + v1.include_router(auth_router, prefix="/auth", tags=["Auth"]) | ||
| 14 | + v1.include_router(org_router, prefix="/orgs", tags=["Directory · Orgs"]) | ||
| 15 | + v1.include_router(user_router, prefix="/users", tags=["Directory · Users"]) | ||
| 16 | + app.include_router(v1) | ||
The file is empty
| @@ -0,0 +1,30 @@ | |||
| 1 | +"""认证服务的请求/响应模型。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pydantic import BaseModel | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +class TokenResponse(BaseModel): | ||
| 9 | + """OAuth2 标准 token 响应(/token、/refresh 返回)。""" | ||
| 10 | + | ||
| 11 | + access_token: str | ||
| 12 | + token_type: str = "bearer" | ||
| 13 | + expires_in: int | ||
| 14 | + refresh_token: str | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +class UserOut(BaseModel): | ||
| 18 | + user_id: str | ||
| 19 | + display_name: str | None = None | ||
| 20 | + is_admin: bool = False | ||
| 21 | + status: str | None = None | ||
| 22 | + groups: list[str] = [] | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +class RefreshBody(BaseModel): | ||
| 26 | + refresh_token: str | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +class LogoutBody(BaseModel): | ||
| 30 | + refresh_token: str | ||
| @@ -0,0 +1,58 @@ | |||
| 1 | +"""身份目录管理请求体(组织 / 用户 / 成员)。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pydantic import BaseModel, Field | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +class OrgCreateBody(BaseModel): | ||
| 9 | + group_id: str | None = Field(default=None, max_length=64) | ||
| 10 | + name: str = Field(..., min_length=1, max_length=128) | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +class OrgUpdateBody(BaseModel): | ||
| 14 | + name: str | None = Field(default=None, max_length=128) | ||
| 15 | + status: str | None = Field(default=None, max_length=16) | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +class UserCreateBody(BaseModel): | ||
| 19 | + user_id: str | None = Field(default=None, max_length=64) | ||
| 20 | + display_name: str = Field(..., min_length=1, max_length=128) | ||
| 21 | + is_admin: bool = False | ||
| 22 | + username: str = Field(..., min_length=1, max_length=256) | ||
| 23 | + password: str = Field(..., min_length=1, max_length=256) | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +class UserUpdateBody(BaseModel): | ||
| 27 | + display_name: str | None = Field(default=None, max_length=128) | ||
| 28 | + is_admin: bool | None = None | ||
| 29 | + status: str | None = Field(default=None, max_length=16) | ||
| 30 | + password: str | None = Field(default=None, max_length=256) | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +class SetMembershipBody(BaseModel): | ||
| 34 | + """整体覆盖某用户的组织绑定(批量)。""" | ||
| 35 | + | ||
| 36 | + group_ids: list[str] = Field(default_factory=list) | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +class AddMembersBody(BaseModel): | ||
| 40 | + """从组织侧批量加入用户(幂等)。""" | ||
| 41 | + | ||
| 42 | + user_ids: list[str] = Field(default_factory=list) | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +class UserBatchItem(BaseModel): | ||
| 46 | + """批量新建用户的单行(对应 Excel/CSV 一行)。""" | ||
| 47 | + | ||
| 48 | + username: str = Field(..., min_length=1, max_length=256) | ||
| 49 | + password: str = Field(..., min_length=1, max_length=256) | ||
| 50 | + display_name: str | None = Field(default=None, max_length=128) # 空 → 回退 username | ||
| 51 | + is_admin: bool = False | ||
| 52 | + orgs: list[str] = Field(default_factory=list) # 组织 id 或名称;无效自动忽略 → 无组织 | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +class UsersBatchCreateBody(BaseModel): | ||
| 56 | + """批量新建用户请求体(前端解析 Excel/CSV 后提交 JSON)。""" | ||
| 57 | + | ||
| 58 | + users: list[UserBatchItem] = Field(..., min_length=1, max_length=500) | ||
The file is empty
| @@ -0,0 +1,118 @@ | |||
| 1 | +"""JWT 签名密钥(RS256):落库自举,生成一次→存身份库→所有副本读同一行。 | ||
| 2 | + | ||
| 3 | +k8s 多副本天然一致(DB 即共享密钥库,无需 Secret 同步)。私钥仅本服务持有(签发 | ||
| 4 | +access JWT);公钥经 ``/v1/auth/public_key`` 暴露,供资源服务器本地验签。 | ||
| 5 | + | ||
| 6 | +> 落库范式参考 claw_manager 既有的 `get_or_create_manager_signing_key`,但**表/库/ | ||
| 7 | +> 算法均独立**(本表 `identity_jwt_signing_key`、RSA PEM 文本),函数名 `get_or_create_ | ||
| 8 | +> jwt_signing_key` 以示区分。 | ||
| 9 | +""" | ||
| 10 | + | ||
| 11 | +from __future__ import annotations | ||
| 12 | + | ||
| 13 | +import hashlib | ||
| 14 | +from dataclasses import dataclass | ||
| 15 | + | ||
| 16 | +from cryptography.hazmat.primitives import serialization | ||
| 17 | +from cryptography.hazmat.primitives.asymmetric import rsa | ||
| 18 | +from openjiuwen_runtime.foundation.db.handler import DBHandler | ||
| 19 | + | ||
| 20 | +from identity_center.infrastructure.logger import get_logger | ||
| 21 | +from identity_center.infrastructure.utils import utc_now | ||
| 22 | +from identity_center.models.identity_models import IDENTITY_JWT_SIGNING_KEY_TABLE_DEF | ||
| 23 | + | ||
| 24 | +_log = get_logger(__name__) | ||
| 25 | + | ||
| 26 | +_TABLE = IDENTITY_JWT_SIGNING_KEY_TABLE_DEF.table_name | ||
| 27 | +_SINGLETON_ID = "default" | ||
| 28 | +_ALG = "RS256" | ||
| 29 | +_VERSION = "v1" | ||
| 30 | + | ||
| 31 | +# 进程内缓存(启动时由 load_signing_key 填充)。 | ||
| 32 | +_PRIVATE_PEM: bytes | None = None | ||
| 33 | +_PUBLIC_PEM: bytes | None = None | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +@dataclass(frozen=True) | ||
| 37 | +class JwtSigningKey: | ||
| 38 | + key_version: str | ||
| 39 | + private_pem: bytes | ||
| 40 | + public_pem: bytes | ||
| 41 | + fingerprint: str | ||
| 42 | + | ||
| 43 | + | ||
| 44 | +def _generate_keypair() -> tuple[bytes, bytes]: | ||
| 45 | + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) | ||
| 46 | + priv_pem = key.private_bytes( | ||
| 47 | + encoding=serialization.Encoding.PEM, | ||
| 48 | + format=serialization.PrivateFormat.PKCS8, | ||
| 49 | + encryption_algorithm=serialization.NoEncryption(), | ||
| 50 | + ) | ||
| 51 | + pub_pem = key.public_key().public_bytes( | ||
| 52 | + encoding=serialization.Encoding.PEM, | ||
| 53 | + format=serialization.PublicFormat.SubjectPublicKeyInfo, | ||
| 54 | + ) | ||
| 55 | + return priv_pem, pub_pem | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +def _row_to_key(row: object) -> JwtSigningKey: | ||
| 59 | + return JwtSigningKey( | ||
| 60 | + key_version=str(getattr(row, "key_version", _VERSION)), | ||
| 61 | + private_pem=str(getattr(row, "private_key", "")).encode("utf-8"), | ||
| 62 | + public_pem=str(getattr(row, "public_key", "")).encode("utf-8"), | ||
| 63 | + fingerprint=str(getattr(row, "fingerprint", "")), | ||
| 64 | + ) | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +async def get_or_create_jwt_signing_key(handler: DBHandler) -> JwtSigningKey: | ||
| 68 | + """读取 JWT 签名密钥;不存在则生成 RSA-2048 并落库(单例,幂等,并发安全)。""" | ||
| 69 | + existing = await handler.get(_TABLE, {"id": _SINGLETON_ID}) | ||
| 70 | + if existing is not None: | ||
| 71 | + return _row_to_key(existing) | ||
| 72 | + | ||
| 73 | + priv_pem, pub_pem = _generate_keypair() | ||
| 74 | + fingerprint = hashlib.sha256(pub_pem).hexdigest() | ||
| 75 | + now = utc_now() | ||
| 76 | + try: | ||
| 77 | + await handler.create( | ||
| 78 | + _TABLE, | ||
| 79 | + { | ||
| 80 | + "id": _SINGLETON_ID, | ||
| 81 | + "sign_alg": _ALG, | ||
| 82 | + "private_key": priv_pem.decode("utf-8"), | ||
| 83 | + "public_key": pub_pem.decode("utf-8"), | ||
| 84 | + "key_version": _VERSION, | ||
| 85 | + "fingerprint": fingerprint, | ||
| 86 | + "created_at": now, | ||
| 87 | + "updated_at": now, | ||
| 88 | + }, | ||
| 89 | + ) | ||
| 90 | + _log.info("[jwt] generated & persisted RS256 signing key", version=_VERSION, fp=fingerprint[:16]) | ||
| 91 | + except Exception: # noqa: BLE001 | ||
| 92 | + # 并发下另一副本可能抢先建好 → 回读返回那一行(保证全副本同一密钥)。 | ||
| 93 | + again = await handler.get(_TABLE, {"id": _SINGLETON_ID}) | ||
| 94 | + if again is not None: | ||
| 95 | + return _row_to_key(again) | ||
| 96 | + raise | ||
| 97 | + return JwtSigningKey(_VERSION, priv_pem, pub_pem, fingerprint) | ||
| 98 | + | ||
| 99 | + | ||
| 100 | +async def load_signing_key(handler: DBHandler) -> JwtSigningKey: | ||
| 101 | + """启动时调用:落库自举 + 填充进程缓存。""" | ||
| 102 | + global _PRIVATE_PEM, _PUBLIC_PEM | ||
| 103 | + key = await get_or_create_jwt_signing_key(handler) | ||
| 104 | + _PRIVATE_PEM, _PUBLIC_PEM = key.private_pem, key.public_pem | ||
| 105 | + _log.info("[jwt] loaded signing key from DB", version=key.key_version, fp=key.fingerprint[:16]) | ||
| 106 | + return key | ||
| 107 | + | ||
| 108 | + | ||
| 109 | +def private_pem() -> bytes: | ||
| 110 | + if _PRIVATE_PEM is None: | ||
| 111 | + raise RuntimeError("JWT signing key not loaded; call load_signing_key(handler) at startup") | ||
| 112 | + return _PRIVATE_PEM | ||
| 113 | + | ||
| 114 | + | ||
| 115 | +def public_pem() -> bytes: | ||
| 116 | + if _PUBLIC_PEM is None: | ||
| 117 | + raise RuntimeError("JWT signing key not loaded; call load_signing_key(handler) at startup") | ||
| 118 | + return _PUBLIC_PEM | ||
| @@ -0,0 +1,58 @@ | |||
| 1 | +"""Access JWT 的签发与校验(RS256)。 | ||
| 2 | + | ||
| 3 | +claims:``sub``(user_id)、``name``、``is_admin``、``groups``(组织 id 列表)、 | ||
| 4 | +``iss``/``aud``/``iat``/``exp``/``jti``/``typ=access``。资源服务器用公钥验签即可, | ||
| 5 | +无需回调本服务。 | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +from __future__ import annotations | ||
| 9 | + | ||
| 10 | +import uuid | ||
| 11 | +from datetime import timedelta | ||
| 12 | +from typing import Any | ||
| 13 | + | ||
| 14 | +import jwt | ||
| 15 | + | ||
| 16 | +from identity_center.infrastructure.config import settings | ||
| 17 | +from identity_center.infrastructure.utils import utc_now | ||
| 18 | +from identity_center.security.jwt_keys import private_pem, public_pem | ||
| 19 | + | ||
| 20 | +_ALG = "RS256" | ||
| 21 | + | ||
| 22 | + | ||
| 23 | +def issue_access_token( | ||
| 24 | + user_id: str, | ||
| 25 | + *, | ||
| 26 | + display_name: str | None = None, | ||
| 27 | + is_admin: bool = False, | ||
| 28 | + groups: list[str] | None = None, | ||
| 29 | +) -> tuple[str, int]: | ||
| 30 | + """返回 (access_token, expires_in_seconds)。""" | ||
| 31 | + now = utc_now() | ||
| 32 | + ttl = int(settings.access_ttl_seconds) | ||
| 33 | + payload: dict[str, Any] = { | ||
| 34 | + "sub": user_id, | ||
| 35 | + "name": display_name or user_id, | ||
| 36 | + "is_admin": bool(is_admin), | ||
| 37 | + "groups": groups or [], | ||
| 38 | + "iss": settings.jwt_issuer, | ||
| 39 | + "aud": settings.jwt_audience, | ||
| 40 | + "iat": now, | ||
| 41 | + "exp": now + timedelta(seconds=ttl), | ||
| 42 | + "jti": str(uuid.uuid4()), | ||
| 43 | + "typ": "access", | ||
| 44 | + } | ||
| 45 | + token = jwt.encode(payload, private_pem(), algorithm=_ALG) | ||
| 46 | + return token, ttl | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +def decode_access_token(token: str) -> dict[str, Any]: | ||
| 50 | + """验签 + 校验 iss/aud/exp;失败抛 jwt 异常。返回 claims。""" | ||
| 51 | + return jwt.decode( | ||
| 52 | + token, | ||
| 53 | + public_pem(), | ||
| 54 | + algorithms=[_ALG], | ||
| 55 | + audience=settings.jwt_audience, | ||
| 56 | + issuer=settings.jwt_issuer, | ||
| 57 | + options={"require": ["exp", "iat", "sub"]}, | ||
| 58 | + ) | ||
| @@ -0,0 +1,53 @@ | |||
| 1 | +[build-system] | ||
| 2 | +requires = ["setuptools>=61.0", "wheel"] | ||
| 3 | +build-backend = "setuptools.build_meta" | ||
| 4 | + | ||
| 5 | +[project] | ||
| 6 | +# 重构:原 jiuwenclaw-manager (Claw Manager),迁入 runtime 后改名 manager-server | ||
| 7 | +name = "manager-server" | ||
| 8 | +version = "0.1.0" | ||
| 9 | +description = "Agent manager server (management plane)" | ||
| 10 | +requires-python = ">=3.11.4,<3.14" | ||
| 11 | +license = { text = "Apache-2.0" } | ||
| 12 | +classifiers = [ | ||
| 13 | + "License :: OSI Approved :: Apache Software License", | ||
| 14 | +] | ||
| 15 | +dependencies = [ | ||
| 16 | + "fastapi>=0.115", | ||
| 17 | + "uvicorn[standard]>=0.30", | ||
| 18 | + "pydantic>=2.0", | ||
| 19 | + "pydantic-settings>=2.0", | ||
| 20 | + "httpx>=0.27", | ||
| 21 | + "structlog>=24.0", | ||
| 22 | + "pyyaml>=6.0", | ||
| 23 | + # 重构:foundation 由 git 源(agent-runtime@develop)改为本仓源码,见 [tool.uv.sources] | ||
| 24 | + "openjiuwen-runtime-foundation", | ||
| 25 | + "websockets>=12.0,<16", | ||
| 26 | + "asyncpg>=0.29", | ||
| 27 | + "cryptography>=42.0", | ||
| 28 | + "PyJWT>=2.8", | ||
| 29 | +] | ||
| 30 | + | ||
| 31 | +[project.optional-dependencies] | ||
| 32 | +dev = [ | ||
| 33 | + "pytest>=9.0.2", | ||
| 34 | + "pytest-asyncio>=1.3.0", | ||
| 35 | + "ruff>=0.6", | ||
| 36 | +] | ||
| 37 | + | ||
| 38 | +# 重构:foundation 改用本仓 editable 源码,不再从 gitcode 拉取 | ||
| 39 | +[tool.uv.sources] | ||
| 40 | +openjiuwen-runtime-foundation = { path = "../../../foundation", editable = true } | ||
| 41 | + | ||
| 42 | +[project.scripts] | ||
| 43 | +manager-server = "manager_server.main:main" | ||
| 44 | +manager-start = "manager_server.start_services:main" | ||
| 45 | +manager-web = "manager_server.manager_web:main" | ||
| 46 | + | ||
| 47 | +[tool.setuptools.packages.find] | ||
| 48 | +where = ["src"] | ||
| 49 | +include = ["manager_server*"] | ||
| 50 | + | ||
| 51 | +[tool.ruff] | ||
| 52 | +src = ["src"] | ||
| 53 | +line-length = 100 | ||
| @@ -0,0 +1,383 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. | ||
| 3 | +"""配置完成后,经 Gateway WebChannel 发送一条用户聊天请求(验证 AgentServer 侧企业策略 / 模型 / Skill / 扩展配置)。 | ||
| 4 | + | ||
| 5 | +依赖:主仓库已安装 ``websockets``(``uv sync`` 或 ``pip install websockets``)。 | ||
| 6 | + | ||
| 7 | +典型用法(先完成 provision-local + ``enterprise_config_demo_data_config.py``,记下 provision 返回的 web 端口):: | ||
| 8 | + | ||
| 9 | + # alice → default/vision 均为 M3 VIP-加强对话;Skill W1;扩展 E3(覆盖服务 E1+E2) | ||
| 10 | + uv run python packages/jiuwenclaw-ee/claw_manager/scripts/enterprise_config_chat.py \\ | ||
| 11 | + --group-id g_demo_sales --bot-id bot_main --user-id alice \\ | ||
| 12 | + --content "用一句话说明当前使用的模型" --web-port 19234 | ||
| 13 | + | ||
| 14 | + # bob → default=M5,vision=M2;Skill W1+W2;扩展 E1+E2(继承服务级) | ||
| 15 | + uv run python .../enterprise_config_chat.py \\ | ||
| 16 | + --group-id g_demo_sales --bot-id bot_main --user-id bob --web-port 19234 | ||
| 17 | + | ||
| 18 | + # g_unknown → 四模型槽位均为 M1;Skill W3;扩展 E4 | ||
| 19 | + uv run python .../enterprise_config_chat.py \\ | ||
| 20 | + --group-id g_unknown --bot-id bot_main --user-id bob --web-port 19234 | ||
| 21 | + | ||
| 22 | +``service_config`` 由 Gateway Runtime 加载,请用 ``enterprise_runtime_service_config.py`` 验证(见数据模型 §3.2)。 | ||
| 23 | + | ||
| 24 | +也可把 provision-local 的 JSON 响应存为文件后自动读端口(同样放末尾):: | ||
| 25 | + | ||
| 26 | + uv run python .../enterprise_config_chat.py \\ | ||
| 27 | + --group-id g_demo_sales --bot-id bot_main --user-id alice \\ | ||
| 28 | + --provision-json provision.json | ||
| 29 | + | ||
| 30 | +连接远程 Gateway WebChannel:: | ||
| 31 | + | ||
| 32 | + uv run python .../enterprise_config_chat.py \\ | ||
| 33 | + --group-id g_demo_sales --bot-id bot_main --user-id alice \\ | ||
| 34 | + --ws-url ws://10.0.0.1:19001/ws | ||
| 35 | +""" | ||
| 36 | + | ||
| 37 | +from __future__ import annotations | ||
| 38 | + | ||
| 39 | +import argparse | ||
| 40 | +import asyncio | ||
| 41 | +import json | ||
| 42 | +import logging | ||
| 43 | +import sys | ||
| 44 | +import uuid | ||
| 45 | +from pathlib import Path | ||
| 46 | +from typing import Any | ||
| 47 | +from urllib.parse import urlparse | ||
| 48 | + | ||
| 49 | +logger = logging.getLogger(__name__) | ||
| 50 | +_stream_logger = logging.getLogger(f"{__name__}.stream") | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +def _configure_cli_logging() -> None: | ||
| 54 | + """INFO 走 stdout,ERROR 走 stderr,便于联调脚本对照终端习惯。""" | ||
| 55 | + root = logging.getLogger() | ||
| 56 | + root.handlers.clear() | ||
| 57 | + root.setLevel(logging.INFO) | ||
| 58 | + fmt = logging.Formatter("%(message)s") | ||
| 59 | + out = logging.StreamHandler(sys.stdout) | ||
| 60 | + out.setLevel(logging.INFO) | ||
| 61 | + out.setFormatter(fmt) | ||
| 62 | + err = logging.StreamHandler(sys.stderr) | ||
| 63 | + err.setLevel(logging.ERROR) | ||
| 64 | + err.setFormatter(fmt) | ||
| 65 | + root.addHandler(out) | ||
| 66 | + root.addHandler(err) | ||
| 67 | + | ||
| 68 | + _stream_logger.handlers.clear() | ||
| 69 | + _stream_logger.propagate = False | ||
| 70 | + _stream_logger.setLevel(logging.INFO) | ||
| 71 | + stream_out = logging.StreamHandler(sys.stdout) | ||
| 72 | + stream_out.setLevel(logging.INFO) | ||
| 73 | + stream_out.setFormatter(fmt) | ||
| 74 | + stream_out.terminator = "" | ||
| 75 | + _stream_logger.addHandler(stream_out) | ||
| 76 | + | ||
| 77 | + | ||
| 78 | +def _write_stream(text: str) -> None: | ||
| 79 | + """流式输出模型片段(非结构化日志,保持单行连续打印)。""" | ||
| 80 | + _stream_logger.info(text) | ||
| 81 | + | ||
| 82 | + | ||
| 83 | +# enterprise_config_demo_data_config.py 写入后的预期槽位(便于联调对照 AgentServer 日志) | ||
| 84 | +_MODEL_SLOT_KEYS = ("default_model", "vision_model", "video_model", "audio_model") | ||
| 85 | + | ||
| 86 | +_DEMO_ENTERPRISE_EXPECTATIONS: dict[tuple[str, str], dict[str, Any]] = { | ||
| 87 | + ("g_demo_sales", "alice"): { | ||
| 88 | + "model_slots": { | ||
| 89 | + "default_model": "M3 VIP-加强对话 (gpt-5)", | ||
| 90 | + "vision_model": "M3 VIP-加强对话 (gpt-5)", | ||
| 91 | + "video_model": "M1 全局兜底-经济型 (gpt-4o-mini)", | ||
| 92 | + "audio_model": "M1 全局兜底-经济型 (gpt-4o-mini)", | ||
| 93 | + }, | ||
| 94 | + "skill_whitelist": "W1 销售组-天气 Skill", | ||
| 95 | + "extension_config": "E3 Agent Server 错误恢复(覆盖服务 E1+E2)", | ||
| 96 | + "note": "2.6.1 覆盖 2.5.1 的 default/vision;video/audio 由 2.7 回填 M1 全局兜底-经济型", | ||
| 97 | + }, | ||
| 98 | + ("g_demo_sales", "bob"): { | ||
| 99 | + "model_slots": { | ||
| 100 | + "default_model": "M5 销售组映射专用 (gpt-4o-group-map)", | ||
| 101 | + "vision_model": "M2 销售组-标准型 (gpt-4o)", | ||
| 102 | + "video_model": "M1 全局兜底-经济型 (gpt-4o-mini)", | ||
| 103 | + "audio_model": "M1 全局兜底-经济型 (gpt-4o-mini)", | ||
| 104 | + }, | ||
| 105 | + "skill_whitelist": "W1 销售组-天气 Skill + W2 销售组-CRM Skill(继承 2.5.1)", | ||
| 106 | + "extension_config": "E1 Gateway 请求前鉴权 + E2 Gateway 请求后日志(继承 2.5.1)", | ||
| 107 | + "note": "default=M5(2.6.2+2.8.2);vision=M2(继承 2.5.1);video/audio=M1 全局兜底-经济型(2.7 回填)", | ||
| 108 | + }, | ||
| 109 | + ("g_unknown", "bob"): { | ||
| 110 | + "model_slots": { | ||
| 111 | + "default_model": "M1 全局兜底-经济型 (gpt-4o-mini)", | ||
| 112 | + "vision_model": "M1 全局兜底-经济型 (gpt-4o-mini)", | ||
| 113 | + "video_model": "M1 全局兜底-经济型 (gpt-4o-mini)", | ||
| 114 | + "audio_model": "M1 全局兜底-经济型 (gpt-4o-mini)", | ||
| 115 | + }, | ||
| 116 | + "skill_whitelist": "W3 全局兜底 Skill", | ||
| 117 | + "extension_config": "E4 Gateway 定时清理", | ||
| 118 | + "note": "未命中服务策略,四槽位均走 2.7 全局兜底(M1 全局兜底-经济型)", | ||
| 119 | + }, | ||
| 120 | +} | ||
| 121 | + | ||
| 122 | + | ||
| 123 | +def _log_demo_expectation(group_id: str, user_id: str) -> None: | ||
| 124 | + hint = _DEMO_ENTERPRISE_EXPECTATIONS.get((group_id, user_id)) | ||
| 125 | + if hint is None: | ||
| 126 | + hint = _DEMO_ENTERPRISE_EXPECTATIONS.get((group_id, "bob")) | ||
| 127 | + if hint is None: | ||
| 128 | + return | ||
| 129 | + slots = hint.get("model_slots") or {} | ||
| 130 | + slot_parts = [f"{k}={slots.get(k, '未配置')}" for k in _MODEL_SLOT_KEYS] | ||
| 131 | + logger.info("[expect] 演示 seed 预期模型槽位: %s", "; ".join(slot_parts)) | ||
| 132 | + if hint.get("note"): | ||
| 133 | + logger.info("[expect] 说明: %s", hint["note"]) | ||
| 134 | + logger.info( | ||
| 135 | + "[expect] skill_whitelist=%s; extension_config=%s", | ||
| 136 | + hint["skill_whitelist"], | ||
| 137 | + hint["extension_config"], | ||
| 138 | + ) | ||
| 139 | + logger.info( | ||
| 140 | + "[expect] 可在 AgentServer 日志中查找 " | ||
| 141 | + "[enterprise_config] loaded enterprise config 确认各槽位 template_ref" | ||
| 142 | + ) | ||
| 143 | + logger.info( | ||
| 144 | + "[expect] service_config 由 Gateway Runtime 验证,见 enterprise_runtime_service_config.py" | ||
| 145 | + ) | ||
| 146 | + | ||
| 147 | + | ||
| 148 | +def _load_web_port_from_provision(path: Path) -> int: | ||
| 149 | + raw = json.loads(path.read_text(encoding="utf-8")) | ||
| 150 | + data = raw.get("data", raw) | ||
| 151 | + ports = data.get("ports") if isinstance(data, dict) else None | ||
| 152 | + if not isinstance(ports, dict): | ||
| 153 | + raise ValueError(f"无法在 {path} 中找到 data.ports") | ||
| 154 | + web = ports.get("web") | ||
| 155 | + if web is None: | ||
| 156 | + raise ValueError(f"无法在 {path} 中找到 data.ports.web") | ||
| 157 | + return int(web) | ||
| 158 | + | ||
| 159 | + | ||
| 160 | +def _resolve_ws_url(args: argparse.Namespace) -> str: | ||
| 161 | + """解析 WebSocket 目标:--ws-url 优先,否则由 host + web 端口 + path 拼装。""" | ||
| 162 | + if args.ws_url: | ||
| 163 | + url = str(args.ws_url).strip() | ||
| 164 | + parsed = urlparse(url) | ||
| 165 | + if parsed.scheme not in ("ws", "wss"): | ||
| 166 | + raise ValueError(f"--ws-url 须为 ws:// 或 wss://,当前 scheme={parsed.scheme!r}") | ||
| 167 | + if not parsed.netloc: | ||
| 168 | + raise ValueError(f"--ws-url 无效(缺少 host): {url!r}") | ||
| 169 | + return url | ||
| 170 | + if args.provision_json is not None: | ||
| 171 | + web_port = _load_web_port_from_provision(args.provision_json) | ||
| 172 | + else: | ||
| 173 | + web_port = int(args.web_port) | ||
| 174 | + return f"ws://{args.host}:{web_port}{args.ws_path}" | ||
| 175 | + | ||
| 176 | + | ||
| 177 | +def _browser_origin_header(ws_url: str) -> dict[str, str]: | ||
| 178 | + """WebChannel 默认校验 Origin;Python websockets 客户端需模拟浏览器 Origin。""" | ||
| 179 | + parsed = urlparse(ws_url) | ||
| 180 | + host = parsed.hostname or "127.0.0.1" | ||
| 181 | + http_scheme = "https" if parsed.scheme == "wss" else "http" | ||
| 182 | + port = parsed.port | ||
| 183 | + default_port = 443 if http_scheme == "https" else 80 | ||
| 184 | + if port is not None and port != default_port: | ||
| 185 | + origin = f"{http_scheme}://{host}:{port}" | ||
| 186 | + else: | ||
| 187 | + origin = f"{http_scheme}://{host}" | ||
| 188 | + return {"Origin": origin} | ||
| 189 | + | ||
| 190 | + | ||
| 191 | +def _parse_args() -> argparse.Namespace: | ||
| 192 | + p = argparse.ArgumentParser(description="经 Gateway /ws 发送 chat.send(企业路由参数在 params 内)") | ||
| 193 | + p.add_argument("--host", default="127.0.0.1", help="Gateway 主机,默认 127.0.0.1") | ||
| 194 | + p.add_argument("--ws-path", default="/ws", help="WebSocket 路径,默认 /ws") | ||
| 195 | + p.add_argument("--session-id", default="", help="会话 ID;留空则由 Gateway 自动生成") | ||
| 196 | + p.add_argument( | ||
| 197 | + "--content", | ||
| 198 | + default="你好,请用一句话回复,并说明你当前使用的模型名称。", | ||
| 199 | + help="用户消息正文(会同时写入 content 与 query)", | ||
| 200 | + ) | ||
| 201 | + p.add_argument("--group-id", default="g_demo_sales", help="企业策略 group_id") | ||
| 202 | + p.add_argument("--bot-id", default="bot_main", help="企业策略 bot_id") | ||
| 203 | + p.add_argument("--user-id", default="alice", help="企业策略 user_id / agent_id 匹配") | ||
| 204 | + p.add_argument("--mode", default="agent.plan", help="运行模式,如 agent.plan") | ||
| 205 | + p.add_argument( | ||
| 206 | + "--timeout", | ||
| 207 | + type=float, | ||
| 208 | + default=180.0, | ||
| 209 | + help="等待 chat.final 的最长时间(秒)", | ||
| 210 | + ) | ||
| 211 | + p.add_argument( | ||
| 212 | + "--print-deltas", | ||
| 213 | + action="store_true", | ||
| 214 | + help="打印 chat.delta 流式片段(默认只打印 final)", | ||
| 215 | + ) | ||
| 216 | + src = p.add_mutually_exclusive_group(required=True) | ||
| 217 | + src.add_argument("--web-port", type=int, help="Gateway WebChannel 端口(provision 返回的 ports.web)") | ||
| 218 | + src.add_argument( | ||
| 219 | + "--provision-json", | ||
| 220 | + type=Path, | ||
| 221 | + help="provision-local 响应 JSON 文件路径(读取 data.ports.web)", | ||
| 222 | + ) | ||
| 223 | + src.add_argument( | ||
| 224 | + "--ws-url", | ||
| 225 | + help="完整 WebSocket URL(连接远程 Gateway),如 ws://host:19001/ws 或 wss://host/ws", | ||
| 226 | + ) | ||
| 227 | + return p.parse_args() | ||
| 228 | + | ||
| 229 | + | ||
| 230 | +async def _recv_json(ws: Any, timeout: float) -> dict[str, Any]: | ||
| 231 | + raw = await asyncio.wait_for(ws.recv(), timeout=timeout) | ||
| 232 | + data = json.loads(raw) | ||
| 233 | + if not isinstance(data, dict): | ||
| 234 | + raise TypeError(f"非 JSON 对象: {raw!r}") | ||
| 235 | + return data | ||
| 236 | + | ||
| 237 | + | ||
| 238 | +async def _run_chat(args: argparse.Namespace) -> int: | ||
| 239 | + import websockets | ||
| 240 | + | ||
| 241 | + ws_url = _resolve_ws_url(args) | ||
| 242 | + | ||
| 243 | + session_id = (args.session_id or "").strip() or f"sess_{uuid.uuid4().hex[:12]}" | ||
| 244 | + req_id = f"req_{uuid.uuid4().hex[:12]}" | ||
| 245 | + | ||
| 246 | + params: dict[str, Any] = { | ||
| 247 | + "session_id": session_id, | ||
| 248 | + "content": args.content, | ||
| 249 | + "query": args.content, | ||
| 250 | + "mode": args.mode, | ||
| 251 | + "group_id": args.group_id, | ||
| 252 | + "bot_id": args.bot_id, | ||
| 253 | + "user_id": args.user_id, | ||
| 254 | + } | ||
| 255 | + | ||
| 256 | + req = { | ||
| 257 | + "type": "req", | ||
| 258 | + "id": req_id, | ||
| 259 | + "method": "chat.send", | ||
| 260 | + "params": params, | ||
| 261 | + } | ||
| 262 | + | ||
| 263 | + logger.info("[connect] %s", ws_url) | ||
| 264 | + logger.info( | ||
| 265 | + "[send] session_id=%s group_id=%s bot_id=%s user_id=%s", | ||
| 266 | + session_id, | ||
| 267 | + args.group_id, | ||
| 268 | + args.bot_id, | ||
| 269 | + args.user_id, | ||
| 270 | + ) | ||
| 271 | + _log_demo_expectation(args.group_id, args.user_id) | ||
| 272 | + logger.info("[send] content=%r", args.content) | ||
| 273 | + | ||
| 274 | + deadline = asyncio.get_running_loop().time() + args.timeout | ||
| 275 | + | ||
| 276 | + ws_headers = _browser_origin_header(ws_url) | ||
| 277 | + async with websockets.connect( | ||
| 278 | + ws_url, | ||
| 279 | + open_timeout=15, | ||
| 280 | + additional_headers=ws_headers, | ||
| 281 | + ) as ws: | ||
| 282 | + await ws.send(json.dumps(req, ensure_ascii=False)) | ||
| 283 | + | ||
| 284 | + accepted = False | ||
| 285 | + while asyncio.get_running_loop().time() < deadline: | ||
| 286 | + remaining = max(0.1, deadline - asyncio.get_running_loop().time()) | ||
| 287 | + try: | ||
| 288 | + frame = await _recv_json(ws, remaining) | ||
| 289 | + except asyncio.TimeoutError: | ||
| 290 | + logger.error("[timeout] 未在时限内收到 chat.final") | ||
| 291 | + return 2 | ||
| 292 | + | ||
| 293 | + ftype = frame.get("type") | ||
| 294 | + | ||
| 295 | + if ftype == "res" and frame.get("id") == req_id: | ||
| 296 | + ok = bool(frame.get("ok")) | ||
| 297 | + payload = frame.get("payload") or {} | ||
| 298 | + logger.info( | ||
| 299 | + "[res] ok=%s payload=%s", | ||
| 300 | + ok, | ||
| 301 | + json.dumps(payload, ensure_ascii=False), | ||
| 302 | + ) | ||
| 303 | + if not ok: | ||
| 304 | + err = frame.get("error") or payload.get("error") or frame | ||
| 305 | + logger.error("[error] %s", err) | ||
| 306 | + return 1 | ||
| 307 | + accepted = bool(payload.get("accepted", True)) | ||
| 308 | + if not accepted: | ||
| 309 | + logger.error("[error] chat.send 未被接受") | ||
| 310 | + return 1 | ||
| 311 | + continue | ||
| 312 | + | ||
| 313 | + if ftype == "event": | ||
| 314 | + event = frame.get("event") | ||
| 315 | + payload = frame.get("payload") or {} | ||
| 316 | + if event == "connection.ack": | ||
| 317 | + logger.info("[event] connection.ack") | ||
| 318 | + continue | ||
| 319 | + if event == "chat.delta" and args.print_deltas: | ||
| 320 | + chunk = payload.get("content") or payload.get("text") or "" | ||
| 321 | + if chunk: | ||
| 322 | + _write_stream(chunk) | ||
| 323 | + continue | ||
| 324 | + if event == "chat.final": | ||
| 325 | + if args.print_deltas: | ||
| 326 | + _write_stream("\n") | ||
| 327 | + final_text = str(payload.get("content") or payload.get("text") or "") | ||
| 328 | + logger.info( | ||
| 329 | + "[event] chat.final session_id=%s", | ||
| 330 | + payload.get("session_id", session_id), | ||
| 331 | + ) | ||
| 332 | + logger.info("%s", final_text or "(empty)") | ||
| 333 | + return 0 | ||
| 334 | + if event == "chat.error": | ||
| 335 | + logger.error( | ||
| 336 | + "[event] chat.error %s", | ||
| 337 | + json.dumps(payload, ensure_ascii=False), | ||
| 338 | + ) | ||
| 339 | + return 1 | ||
| 340 | + logger.info( | ||
| 341 | + "[event] %s %s", | ||
| 342 | + event, | ||
| 343 | + json.dumps(payload, ensure_ascii=False)[:200], | ||
| 344 | + ) | ||
| 345 | + continue | ||
| 346 | + | ||
| 347 | + logger.info("[frame] %s", json.dumps(frame, ensure_ascii=False)[:500]) | ||
| 348 | + | ||
| 349 | + if not accepted: | ||
| 350 | + logger.error("[timeout] 未收到 chat.send 的 res 确认") | ||
| 351 | + return 2 | ||
| 352 | + logger.error("[timeout] 已接受请求但未收到 chat.final") | ||
| 353 | + return 2 | ||
| 354 | + | ||
| 355 | + | ||
| 356 | +def main() -> int: | ||
| 357 | + _configure_cli_logging() | ||
| 358 | + try: | ||
| 359 | + import websockets # noqa: F401 | ||
| 360 | + except ImportError: | ||
| 361 | + logger.error( | ||
| 362 | + "缺少 websockets,请在 jiuwenclaw 仓库根目录执行: uv sync 或 pip install websockets" | ||
| 363 | + ) | ||
| 364 | + return 1 | ||
| 365 | + | ||
| 366 | + args = _parse_args() | ||
| 367 | + try: | ||
| 368 | + return asyncio.run(_run_chat(args)) | ||
| 369 | + except KeyboardInterrupt: | ||
| 370 | + return 130 | ||
| 371 | + except OSError as connect_err: | ||
| 372 | + logger.error("[connect-failed] %s", connect_err) | ||
| 373 | + logger.error( | ||
| 374 | + "请确认 Gateway 已启动,且 --web-port / --provision-json / --ws-url 指向可访问的 WebChannel。" | ||
| 375 | + ) | ||
| 376 | + return 1 | ||
| 377 | + except Exception as err: | ||
| 378 | + logger.error("[failed] %s", err) | ||
| 379 | + return 1 | ||
| 380 | + | ||
| 381 | + | ||
| 382 | +if __name__ == "__main__": | ||
| 383 | + raise SystemExit(main()) | ||
| @@ -0,0 +1,372 @@ | |||
| 1 | +#!/usr/bin/env python3 | ||
| 2 | +# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved. | ||
| 3 | +"""验证 Gateway Runtime Management 按路由上下文加载正确的 ``service_config`` 模板。 | ||
| 4 | + | ||
| 5 | +与 ``RuntimeManagementAgentClient.send_request`` / ``send_request_stream`` 使用同一套 | ||
| 6 | +``load_effective_enterprise_config(..., slots=[service_config])`` 读库逻辑(不依赖 K8s 池)。 | ||
| 7 | +加载 ``service_config`` 槽位时,还会解析命中策略上的 ``service_id`` / ``agent_id`` 模板 | ||
| 8 | +(如 ``${group_id}::${bot_id}``、``${user_id}``),与 Runtime 转发前写入 ``AgentRequest`` 的行为一致。 | ||
| 9 | + | ||
| 10 | +前置:已完成 ``provision-local``、``enterprise_config_demo_data_config.py`` 写入演示策略。 | ||
| 11 | +数据库连接与 Gateway 进程一致,由 ``manager_ws_client.infrastructure.db.Database`` 按 ``GATEWAY_*`` / | ||
| 12 | +``.env`` 解析(支持 sqlite / mysql),脚本侧无需再传数据目录。 | ||
| 13 | + | ||
| 14 | +典型用法(PowerShell,项目根目录;请在仓库根 ``.env`` 中配置与 Gateway 相同的 ``GATEWAY_*``):: | ||
| 15 | + | ||
| 16 | + # 单场景:alice → S1 销售组 AgentServer 池(2.5.1) | ||
| 17 | + uv run python packages/jiuwenclaw-ee/claw_manager/scripts/enterprise_runtime_service_config.py \\ | ||
| 18 | + --group-id g_demo_sales --bot-id bot_main --user-id alice b26bc496-dfee-488b-a2ab-8bae8ce94985 | ||
| 19 | + | ||
| 20 | + # 一次跑完文档 3.2.1–3.2.3 三个演示场景 | ||
| 21 | + uv run python packages/jiuwenclaw-ee/claw_manager/scripts/enterprise_runtime_service_config.py \\ | ||
| 22 | + --provision-json provision.json --all-scenarios | ||
| 23 | + | ||
| 24 | +``--provision-json`` 可自动读取 ``jiuwenclaw_id``。 | ||
| 25 | +""" | ||
| 26 | + | ||
| 27 | +from __future__ import annotations | ||
| 28 | + | ||
| 29 | +import argparse | ||
| 30 | +import asyncio | ||
| 31 | +import importlib | ||
| 32 | +import json | ||
| 33 | +import logging | ||
| 34 | +import sys | ||
| 35 | +from dataclasses import dataclass | ||
| 36 | +from pathlib import Path | ||
| 37 | +from typing import Any | ||
| 38 | + | ||
| 39 | +logger = logging.getLogger(__name__) | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +@dataclass(frozen=True) | ||
| 43 | +class ServiceConfigExpectation: | ||
| 44 | + label: str | ||
| 45 | + template_name: str | ||
| 46 | + min_idle_services: int | ||
| 47 | + max_services: int | ||
| 48 | + policy_source: str | ||
| 49 | + service_id: str | None | ||
| 50 | + agent_id: str | None | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +_DEMO_SCENARIOS: dict[tuple[str, str], ServiceConfigExpectation] = { | ||
| 54 | + ("g_demo_sales", "alice"): ServiceConfigExpectation( | ||
| 55 | + label="S1 销售组 AgentServer 池", | ||
| 56 | + template_name="销售组 AgentServer 池", | ||
| 57 | + min_idle_services=2, | ||
| 58 | + max_services=10, | ||
| 59 | + policy_source="2.5.1", | ||
| 60 | + service_id="g_demo_sales::bot_main", | ||
| 61 | + agent_id="alice", | ||
| 62 | + ), | ||
| 63 | + ("g_demo_sales", "bob"): ServiceConfigExpectation( | ||
| 64 | + label="S1 销售组 AgentServer 池", | ||
| 65 | + template_name="销售组 AgentServer 池", | ||
| 66 | + min_idle_services=2, | ||
| 67 | + max_services=10, | ||
| 68 | + policy_source="继承 2.5.1", | ||
| 69 | + service_id="g_demo_sales::bot_main", | ||
| 70 | + agent_id="default_agent_id_1", | ||
| 71 | + ), | ||
| 72 | + ("g_unknown", "bob"): ServiceConfigExpectation( | ||
| 73 | + label="S2 全局兜底 AgentServer 池", | ||
| 74 | + template_name="全局兜底 AgentServer 池", | ||
| 75 | + min_idle_services=1, | ||
| 76 | + max_services=5, | ||
| 77 | + policy_source="2.7", | ||
| 78 | + service_id=None, | ||
| 79 | + agent_id=None, | ||
| 80 | + ), | ||
| 81 | +} | ||
| 82 | + | ||
| 83 | +_ALL_SCENARIO_KEYS = ( | ||
| 84 | + ("g_demo_sales", "bot_main", "alice"), | ||
| 85 | + ("g_demo_sales", "bot_main", "bob"), | ||
| 86 | + ("g_unknown", "bot_main", "bob"), | ||
| 87 | +) | ||
| 88 | + | ||
| 89 | + | ||
| 90 | +def _configure_cli_logging() -> None: | ||
| 91 | + root = logging.getLogger() | ||
| 92 | + root.handlers.clear() | ||
| 93 | + root.setLevel(logging.INFO) | ||
| 94 | + fmt = logging.Formatter("%(message)s") | ||
| 95 | + out = logging.StreamHandler(sys.stdout) | ||
| 96 | + out.setLevel(logging.INFO) | ||
| 97 | + out.setFormatter(fmt) | ||
| 98 | + err = logging.StreamHandler(sys.stderr) | ||
| 99 | + err.setLevel(logging.ERROR) | ||
| 100 | + err.setFormatter(fmt) | ||
| 101 | + root.addHandler(out) | ||
| 102 | + root.addHandler(err) | ||
| 103 | + | ||
| 104 | + | ||
| 105 | +def _load_jiuwenclaw_id_from_provision(path: Path) -> str: | ||
| 106 | + raw = json.loads(path.read_text(encoding="utf-8")) | ||
| 107 | + data = raw.get("data", raw) | ||
| 108 | + if not isinstance(data, dict): | ||
| 109 | + raise ValueError(f"无法在 {path} 中找到 data 对象") | ||
| 110 | + jiuwenclaw_id = str(data.get("jiuwenclaw_id") or "").strip() | ||
| 111 | + if not jiuwenclaw_id: | ||
| 112 | + raise ValueError(f"无法在 {path} 中找到 data.jiuwenclaw_id") | ||
| 113 | + return jiuwenclaw_id | ||
| 114 | + | ||
| 115 | + | ||
| 116 | +def _bootstrap_modules() -> tuple[Any, Any, Any, Any, Any]: | ||
| 117 | + from jiuwenclaw.infrastructure.module_importer import ( | ||
| 118 | + ensure_manager_ws_client_package, | ||
| 119 | + import_manager_ws_client_module, | ||
| 120 | + ) | ||
| 121 | + | ||
| 122 | + ext_root = ensure_manager_ws_client_package() | ||
| 123 | + loader_mod = import_manager_ws_client_module("core.enterprise_config.loader") | ||
| 124 | + schemas_mod = import_manager_ws_client_module("core.enterprise_config.schemas") | ||
| 125 | + utils_mod = import_manager_ws_client_module("infrastructure.utils") | ||
| 126 | + db_mod = import_manager_ws_client_module("infrastructure.db") | ||
| 127 | + gateway_db_mod = import_manager_ws_client_module("core.enterprise_config.gateway_db") | ||
| 128 | + database = db_mod.Database(relative_root=ext_root) | ||
| 129 | + return ( | ||
| 130 | + loader_mod.load_effective_enterprise_config, | ||
| 131 | + schemas_mod.TemplateRefSlot.SERVICE_CONFIG, | ||
| 132 | + utils_mod.set_jiuwenclaw_id, | ||
| 133 | + database, | ||
| 134 | + gateway_db_mod, | ||
| 135 | + ) | ||
| 136 | + | ||
| 137 | + | ||
| 138 | +async def _ensure_gateway_database(database: Any) -> None: | ||
| 139 | + """与 Gateway / enterprise_config 读库相同:经 ``Database.ensure_ready`` 连接。""" | ||
| 140 | + await database.ensure_ready(log_prefix="enterprise_runtime_verify") | ||
| 141 | + summary = database.config_summary() | ||
| 142 | + logger.info("[db] gateway database ready: %s", summary) | ||
| 143 | + if summary.get("db_type") == "sqlite": | ||
| 144 | + sqlite_path = Path(str(summary.get("sqlite_path", ""))) | ||
| 145 | + if not sqlite_path.is_file(): | ||
| 146 | + logger.warning("[db] sqlite 文件不存在: %s", sqlite_path) | ||
| 147 | + | ||
| 148 | + | ||
| 149 | +async def _close_gateway_databases(database: Any, gateway_db_mod: Any) -> None: | ||
| 150 | + """关闭脚本与 ``enterprise_config.gateway_db`` 各自持有的连接。""" | ||
| 151 | + enterprise_db = getattr(gateway_db_mod, "_db", None) | ||
| 152 | + for label, db in ( | ||
| 153 | + ("enterprise_runtime_verify", database), | ||
| 154 | + ("enterprise_config", enterprise_db), | ||
| 155 | + ): | ||
| 156 | + if db is None: | ||
| 157 | + continue | ||
| 158 | + try: | ||
| 159 | + await db.close() | ||
| 160 | + except Exception as exc: | ||
| 161 | + logger.warning("[db] %s disconnect error: %s", label, exc) | ||
| 162 | + | ||
| 163 | + | ||
| 164 | +class _RoutingRequest: | ||
| 165 | + def __init__( | ||
| 166 | + self, | ||
| 167 | + *, | ||
| 168 | + group_id: str, | ||
| 169 | + bot_id: str, | ||
| 170 | + user_id: str, | ||
| 171 | + ) -> None: | ||
| 172 | + self.params = { | ||
| 173 | + "group_id": group_id, | ||
| 174 | + "bot_id": bot_id, | ||
| 175 | + "user_id": user_id, | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + | ||
| 179 | +def _log_expectation(group_id: str, user_id: str) -> ServiceConfigExpectation | None: | ||
| 180 | + expect = _DEMO_SCENARIOS.get((group_id, user_id)) | ||
| 181 | + if expect is None: | ||
| 182 | + expect = _DEMO_SCENARIOS.get((group_id, "bob")) | ||
| 183 | + if expect is None: | ||
| 184 | + logger.warning("[expect] 未找到演示 seed 对照表(group_id=%s user_id=%s)", group_id, user_id) | ||
| 185 | + return None | ||
| 186 | + logger.info( | ||
| 187 | + "[expect] service_config=%s (%s); template_name=%r; min_idle=%s max_services=%s; " | ||
| 188 | + "service_id=%r agent_id=%r", | ||
| 189 | + expect.label, | ||
| 190 | + expect.policy_source, | ||
| 191 | + expect.template_name, | ||
| 192 | + expect.min_idle_services, | ||
| 193 | + expect.max_services, | ||
| 194 | + expect.service_id, | ||
| 195 | + expect.agent_id, | ||
| 196 | + ) | ||
| 197 | + return expect | ||
| 198 | + | ||
| 199 | + | ||
| 200 | +def _entity_matches(entity: dict[str, Any], expect: ServiceConfigExpectation) -> list[str]: | ||
| 201 | + errors: list[str] = [] | ||
| 202 | + name = str(entity.get("template_name") or "") | ||
| 203 | + if name != expect.template_name: | ||
| 204 | + errors.append(f"template_name={name!r} 预期 {expect.template_name!r}") | ||
| 205 | + for field in ("min_idle_services", "max_services"): | ||
| 206 | + actual = entity.get(field) | ||
| 207 | + expected = getattr(expect, field) | ||
| 208 | + try: | ||
| 209 | + if int(actual) != int(expected): | ||
| 210 | + errors.append(f"{field}={actual} 预期 {expected}") | ||
| 211 | + except (TypeError, ValueError): | ||
| 212 | + errors.append(f"{field}={actual!r} 无法解析为整数") | ||
| 213 | + return errors | ||
| 214 | + | ||
| 215 | + | ||
| 216 | +def _policy_ids_match(loaded: Any, expect: ServiceConfigExpectation) -> list[str]: | ||
| 217 | + errors: list[str] = [] | ||
| 218 | + if loaded.service_id != expect.service_id: | ||
| 219 | + errors.append( | ||
| 220 | + f"service_id={loaded.service_id!r} 预期 {expect.service_id!r}" | ||
| 221 | + ) | ||
| 222 | + if loaded.agent_id != expect.agent_id: | ||
| 223 | + errors.append( | ||
| 224 | + f"agent_id={loaded.agent_id!r} 预期 {expect.agent_id!r}" | ||
| 225 | + ) | ||
| 226 | + return errors | ||
| 227 | + | ||
| 228 | + | ||
| 229 | +async def _verify_one( | ||
| 230 | + *, | ||
| 231 | + jiuwenclaw_id: str, | ||
| 232 | + group_id: str, | ||
| 233 | + bot_id: str, | ||
| 234 | + user_id: str, | ||
| 235 | + load_fn: Any, | ||
| 236 | + service_config_slot: Any, | ||
| 237 | +) -> int: | ||
| 238 | + expect = _log_expectation(group_id, user_id) | ||
| 239 | + logger.info( | ||
| 240 | + "[load] jiuwenclaw_id=%s group_id=%s bot_id=%s user_id=%s", | ||
| 241 | + jiuwenclaw_id, | ||
| 242 | + group_id, | ||
| 243 | + bot_id, | ||
| 244 | + user_id, | ||
| 245 | + ) | ||
| 246 | + | ||
| 247 | + request = _RoutingRequest(group_id=group_id, bot_id=bot_id, user_id=user_id) | ||
| 248 | + loaded = await load_fn(request, [service_config_slot]) | ||
| 249 | + if loaded is None: | ||
| 250 | + logger.error("[fail] 未加载到 service_config(策略未命中或模板不存在)") | ||
| 251 | + return 1 | ||
| 252 | + | ||
| 253 | + entities = loaded.service_config or [] | ||
| 254 | + if not entities: | ||
| 255 | + logger.error("[fail] service_config 为空 template_ref=%s", loaded.template_ref) | ||
| 256 | + return 1 | ||
| 257 | + | ||
| 258 | + entity = entities[0] | ||
| 259 | + logger.info( | ||
| 260 | + "[loaded] template_id=%s template_name=%s min_idle_services=%s max_services=%s " | ||
| 261 | + "service_policy_id=%s global_policy_id=%s service_id=%s agent_id=%s", | ||
| 262 | + entity.get("template_id"), | ||
| 263 | + entity.get("template_name"), | ||
| 264 | + entity.get("min_idle_services"), | ||
| 265 | + entity.get("max_services"), | ||
| 266 | + loaded.service_policy_id, | ||
| 267 | + loaded.global_policy_id, | ||
| 268 | + loaded.service_id, | ||
| 269 | + loaded.agent_id, | ||
| 270 | + ) | ||
| 271 | + | ||
| 272 | + if expect is None: | ||
| 273 | + logger.info("[ok] 已加载 service_config(无演示对照表,跳过字段断言)") | ||
| 274 | + return 0 | ||
| 275 | + | ||
| 276 | + errors = _entity_matches(entity, expect) | ||
| 277 | + errors.extend(_policy_ids_match(loaded, expect)) | ||
| 278 | + if errors: | ||
| 279 | + for err in errors: | ||
| 280 | + logger.error("[fail] %s", err) | ||
| 281 | + return 1 | ||
| 282 | + | ||
| 283 | + logger.info("[ok] service_config 与演示 seed 预期一致(%s)", expect.label) | ||
| 284 | + return 0 | ||
| 285 | + | ||
| 286 | + | ||
| 287 | +async def _run(args: argparse.Namespace) -> int: | ||
| 288 | + jiuwenclaw_id = (args.jiuwenclaw_id or "").strip() | ||
| 289 | + | ||
| 290 | + if args.provision_json is not None and not jiuwenclaw_id: | ||
| 291 | + jiuwenclaw_id = _load_jiuwenclaw_id_from_provision(args.provision_json) | ||
| 292 | + | ||
| 293 | + if not jiuwenclaw_id: | ||
| 294 | + logger.error("缺少 jiuwenclaw_id( positional 或 --provision-json)") | ||
| 295 | + return 1 | ||
| 296 | + | ||
| 297 | + load_fn, service_config_slot, set_jiuwenclaw_id, database, gateway_db_mod = ( | ||
| 298 | + _bootstrap_modules() | ||
| 299 | + ) | ||
| 300 | + set_jiuwenclaw_id(jiuwenclaw_id) | ||
| 301 | + await _ensure_gateway_database(database) | ||
| 302 | + | ||
| 303 | + try: | ||
| 304 | + if args.all_scenarios: | ||
| 305 | + exit_code = 0 | ||
| 306 | + for group_id, bot_id, user_id in _ALL_SCENARIO_KEYS: | ||
| 307 | + logger.info("") | ||
| 308 | + logger.info("=== 场景 group_id=%s user_id=%s ===", group_id, user_id) | ||
| 309 | + code = await _verify_one( | ||
| 310 | + jiuwenclaw_id=jiuwenclaw_id, | ||
| 311 | + group_id=group_id, | ||
| 312 | + bot_id=bot_id, | ||
| 313 | + user_id=user_id, | ||
| 314 | + load_fn=load_fn, | ||
| 315 | + service_config_slot=service_config_slot, | ||
| 316 | + ) | ||
| 317 | + if code != 0: | ||
| 318 | + exit_code = code | ||
| 319 | + return exit_code | ||
| 320 | + | ||
| 321 | + return await _verify_one( | ||
| 322 | + jiuwenclaw_id=jiuwenclaw_id, | ||
| 323 | + group_id=args.group_id, | ||
| 324 | + bot_id=args.bot_id, | ||
| 325 | + user_id=args.user_id, | ||
| 326 | + load_fn=load_fn, | ||
| 327 | + service_config_slot=service_config_slot, | ||
| 328 | + ) | ||
| 329 | + finally: | ||
| 330 | + await _close_gateway_databases(database, gateway_db_mod) | ||
| 331 | + | ||
| 332 | + | ||
| 333 | +def _parse_args() -> argparse.Namespace: | ||
| 334 | + p = argparse.ArgumentParser( | ||
| 335 | + description="验证 Gateway Runtime Management 的 service_config 企业策略加载", | ||
| 336 | + ) | ||
| 337 | + p.add_argument( | ||
| 338 | + "jiuwenclaw_id", | ||
| 339 | + nargs="?", | ||
| 340 | + default="", | ||
| 341 | + help="provision-local 返回的 jiuwenclaw_id(positional,建议放在命令末尾;可与 --provision-json 二选一)", | ||
| 342 | + ) | ||
| 343 | + p.add_argument("--group-id", default="g_demo_sales", help="企业策略 group_id") | ||
| 344 | + p.add_argument("--bot-id", default="bot_main", help="企业策略 bot_id") | ||
| 345 | + p.add_argument("--user-id", default="alice", help="企业策略 user_id") | ||
| 346 | + p.add_argument( | ||
| 347 | + "--provision-json", | ||
| 348 | + type=Path, | ||
| 349 | + help="provision-local 响应 JSON(读取 jiuwenclaw_id)", | ||
| 350 | + ) | ||
| 351 | + p.add_argument( | ||
| 352 | + "--all-scenarios", | ||
| 353 | + action="store_true", | ||
| 354 | + help="依次验证文档 3.2.1 alice / 3.2.2 bob / 3.2.3 g_unknown 三个 service_config 场景", | ||
| 355 | + ) | ||
| 356 | + return p.parse_args() | ||
| 357 | + | ||
| 358 | + | ||
| 359 | +def main() -> int: | ||
| 360 | + _configure_cli_logging() | ||
| 361 | + args = _parse_args() | ||
| 362 | + try: | ||
| 363 | + return asyncio.run(_run(args)) | ||
| 364 | + except KeyboardInterrupt: | ||
| 365 | + return 130 | ||
| 366 | + except Exception as exc: | ||
| 367 | + logger.error("[failed] %s", exc) | ||
| 368 | + return 1 | ||
| 369 | + | ||
| 370 | + | ||
| 371 | +if __name__ == "__main__": | ||
| 372 | + raise SystemExit(main()) | ||
| @@ -0,0 +1,22 @@ | |||
| 1 | +{ | ||
| 2 | + "enabled": true, | ||
| 3 | + "defaults": "ask", | ||
| 4 | + "tools": { | ||
| 5 | + "bash": "deny", | ||
| 6 | + "todo_list": "allow" | ||
| 7 | + }, | ||
| 8 | + "rules": [ | ||
| 9 | + { | ||
| 10 | + "id": "demo_allow_echo", | ||
| 11 | + "pattern": "echo *", | ||
| 12 | + "action": "allow" | ||
| 13 | + } | ||
| 14 | + ], | ||
| 15 | + "approval_overrides": [], | ||
| 16 | + "file_guard": { | ||
| 17 | + "workspace": { "rw_enabled": true }, | ||
| 18 | + "global": {}, | ||
| 19 | + "trusted_exec_directory": [], | ||
| 20 | + "tool_bindings": {} | ||
| 21 | + } | ||
| 22 | +} | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | +"""JiuwenClaw Manager — EE 管理平面服务。""" | ||
| 2 | + | ||
| 3 | +# 重构:jiuwenclaw 仅本地 provision(subprocess 拉起 gateway/agent)时需要,服务本身(REST/验签)不需要。 | ||
| 4 | +# 迁移期不做 provision 且 jiuwenclaw 不在 runtime 内,故容错降级、不阻塞导入(provision 真用到时再报错)。 | ||
| 5 | +try: | ||
| 6 | + from manager_server.infrastructure.jiuwenclaw_importer import ensure_jiuwenclaw_importable | ||
| 7 | + | ||
| 8 | + ensure_jiuwenclaw_importable() | ||
| 9 | +except ImportError: | ||
| 10 | + pass | ||
| 11 | + | ||
| 12 | +__version__ = "0.1.0" | ||
| @@ -0,0 +1,90 @@ | |||
| 1 | +"""FastAPI 应用工厂。""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import asyncio | ||
| 6 | +from contextlib import asynccontextmanager | ||
| 7 | + | ||
| 8 | +from fastapi import FastAPI | ||
| 9 | +from fastapi.middleware.cors import CORSMiddleware | ||
| 10 | + | ||
| 11 | +from manager_server import __version__ | ||
| 12 | +from manager_server.infrastructure.config import settings | ||
| 13 | +from manager_server.infrastructure.db import create_db_handler, database_config_summary | ||
| 14 | +from manager_server.infrastructure.logger import configure_logging, get_logger | ||
| 15 | +from manager_server.models.table_init import init_all_tables | ||
| 16 | +from manager_server.routers.register import router_register | ||
| 17 | +from manager_server.schedulers.heartbeat_scanner import run_heartbeat_scan_loop | ||
| 18 | + | ||
| 19 | +_log = get_logger(__name__) | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +@asynccontextmanager | ||
| 23 | +async def lifespan(application: FastAPI): | ||
| 24 | + from manager_server.manager_ws_server import ManagerWsServer | ||
| 25 | + | ||
| 26 | + configure_logging() | ||
| 27 | + db_handler = create_db_handler() | ||
| 28 | + application.state.db_handler = db_handler | ||
| 29 | + await db_handler.init_database() | ||
| 30 | + await db_handler.connect() | ||
| 31 | + await init_all_tables(db_handler) | ||
| 32 | + # 身份/账号种子已移至独立认证服务(jiuwenclaw_identity);管理库不再播种用户/组织。 | ||
| 33 | + # 加载/生成 Manager 签名密钥对(Ed25519),供握手下发公钥与下发加签使用。 | ||
| 34 | + from manager_server.security.keys import get_or_create_manager_signing_key | ||
| 35 | + from manager_server.security.sign_provider import set_manager_signing_key | ||
| 36 | + | ||
| 37 | + set_manager_signing_key(await get_or_create_manager_signing_key(db_handler)) | ||
| 38 | + stop = asyncio.Event() | ||
| 39 | + scan_task = asyncio.create_task(run_heartbeat_scan_loop(stop, db_handler)) | ||
| 40 | + if settings.manager_ws_enabled: | ||
| 41 | + manager_ws_server = ManagerWsServer( | ||
| 42 | + host=settings.manager_ws_host, | ||
| 43 | + port=settings.manager_ws_port, | ||
| 44 | + ) | ||
| 45 | + await manager_ws_server.start() | ||
| 46 | + ManagerWsServer.set_instance(manager_ws_server) | ||
| 47 | + _log.info( | ||
| 48 | + "startup", | ||
| 49 | + version=__version__, | ||
| 50 | + db=database_config_summary(), | ||
| 51 | + manager_ws=( | ||
| 52 | + f"ws://{settings.manager_ws_host}:{settings.manager_ws_port}" | ||
| 53 | + if settings.manager_ws_enabled | ||
| 54 | + else None | ||
| 55 | + ), | ||
| 56 | + ) | ||
| 57 | + yield | ||
| 58 | + stop.set() | ||
| 59 | + scan_task.cancel() | ||
| 60 | + try: | ||
| 61 | + await scan_task | ||
| 62 | + except asyncio.CancelledError: | ||
| 63 | + pass | ||
| 64 | + manager_ws_server = ManagerWsServer.get_instance() | ||
| 65 | + if manager_ws_server is not None: | ||
| 66 | + ManagerWsServer.set_instance(None) | ||
| 67 | + await manager_ws_server.stop() | ||
| 68 | + await db_handler.disconnect() | ||
| 69 | + _log.info("shutdown") | ||
| 70 | + | ||
| 71 | + | ||
| 72 | +def create_app() -> FastAPI: | ||
| 73 | + application = FastAPI( | ||
| 74 | + title="manager-server", | ||
| 75 | + description="JiuwenClaw EE 管理平面(Claw Manager)", | ||
| 76 | + version=__version__, | ||
| 77 | + lifespan=lifespan, | ||
| 78 | + ) | ||
| 79 | + application.add_middleware( | ||
| 80 | + CORSMiddleware, | ||
| 81 | + allow_origins=["*"], | ||
| 82 | + allow_credentials=True, | ||
| 83 | + allow_methods=["*"], | ||
| 84 | + allow_headers=["*"], | ||
| 85 | + ) | ||
| 86 | + router_register(application) | ||
| 87 | + return application | ||
| 88 | + | ||
| 89 | + | ||
| 90 | +app = create_app() | ||
| @@ -0,0 +1 @@ | |||
| 1 | +"""业务核心层(请从子包直接导入,勿在本包 ``__init__`` 聚合导出,避免循环依赖)。""" | ||
| @@ -0,0 +1,16 @@ | |||
| 1 | +from .channel_config import push_channel_config_op | ||
| 2 | +from .logging_config import push_logging_config_op | ||
| 3 | +from .embed_config import push_embed_config_op | ||
| 4 | +from .task_memory_config import push_task_memory_config_op | ||
| 5 | + | ||
| 6 | +from .permissions_config import push_permissions_config_op | ||
| 7 | +from .memory_config import push_memory_config_op | ||
| 8 | + | ||
| 9 | +__all__ = ( | ||
| 10 | + "push_channel_config_op", | ||
| 11 | + "push_logging_config_op", | ||
| 12 | + "push_embed_config_op", | ||
| 13 | + "push_task_memory_config_op", | ||
| 14 | + "push_permissions_config_op", | ||
| 15 | + "push_memory_config_op", | ||
| 16 | +) | ||