AuthKeystone IDaaS 下游接入指南
AuthKeystone 是 OIDC 身份提供方(IDaaS),下游业务服务(ExoMind/wechat/…)通过标准 OIDC 接入。 本文档是下游接入的契约与规范——照着做,身份层零踩坑。定稿 2026-07-11。
1. 四层身份模型(务必理解)
AuthKeystone 的身份架构是四层正交,下游接入前必须分清:
| 层 | 字段 | 语义 | 下游怎么用 |
|---|---|---|---|
| 身份(Who) | sub | 用户唯一稳定标识(= user_id,跨改名/换组织不变) | 用户主键:个人知识库、配额、偏好、会话——所有「按用户隔离」的场景都用它 |
| 组织(Whose) | org_id | 组织容器(个人/团队/企业) | 仅团队/企业数据隔离;个人场景不需要。绝不用 org_id 当用户身份 |
| 权限(What) | permissions | RBAC 权限列表 | 授权判断(或调 auth check_permission) |
| 客户端(Which) | aud | 目标业务(client_id) | 验 token 时校验 aud == 自己的 client_id,防 token 跨业务误用 |
铁律:sub = 用户身份主键,org_id = 组织容器。两者正交,绝不混用。
- ❌ 错误:用 org_id 做个人知识库 key(org_id=‘default’ 对所有 default 用户一样 → 串用户)
- ✅ 正确:用 sub 做个人知识库 key(每人唯一),org_id 仅做团队空间隔离
2. 接入流程
2.1 在 auth 注册 OAuth client
平台管理员在 AuthKeystone 后台为你的服务创建 OAuth client,得到:
client_id/client_secret(机密客户端)或仅client_id(公开客户端,前端 SPA,强制 PKCE)redirect_uri(你的回调地址)grant_types(authorization_code / password / client_credentials / refresh_token)
2.2 声明用哪些 scope(字段契约)
在 AuthKeystone 后台「OAuth 客户端 → Scope 配置」勾选你的服务需要的 scope。每个 scope 对应一组 claim(token 字段):
| scope | 暴露的 claim | 谁该要 |
|---|---|---|
openid(默认,所有 client) | sub | 所有 |
profile | preferred_username, name | 要展示用户名的(picture 头像字段规划中,暂不签发) |
email | email, email_verified | 要邮箱的 |
auth:permissions | permissions[] | 要做 RBAC 授权的 |
auth:org | org_id | 要做团队/企业隔离的(个人场景不要) |
业务自定义(如 exomind:wiki) | 业务约定 | 特定业务,向 SaaS 申请 |
不声明的 scope → token 里没有对应 claim(最小暴露)。例如只要 openid + profile → token 只有 sub + preferred_username,拿不到 org_id/email/permissions。
2.3 OIDC 授权码流程(Web 应用,推荐)
用户访问你的服务
→ 你重定向到 AuthKeystone 的 /authorize:
https://auth.ai-as.cc/authorize?
client_id=你的client_id
&redirect_uri=你的回调
&response_type=code
&scope=openid+profile+email(你需要的)
&state=随机(防CSRF)
&code_challenge=PKCE(公开客户端必填)
→ 用户在 auth 登录(或已登录则自动 SSO)
→ auth 回调你的 redirect_uri?code=xxx&state=xxx
→ 你的后端用 code 换 token:
POST https://auth.ai-as.cc/oauth/token
grant_type=authorization_code
code=xxx
client_id / client_secret(机密)或 code_verifier(公开 PKCE)
→ 返回 {access_token, id_token, refresh_token}
→ 你的后端用 JWKS 公钥本地验签 id_token/access_token
→ 取 sub(user_id)做用户身份 + 按 permissions 授权
→ 发你自己的 session cookie
⚡ 前端性能:预热到 auth.ai-as.cc 的连接(建议加)
用户点“AuthKeystone 登录“时,浏览器要从你的域名跳转到 auth.ai-as.cc。若此前没访问过该域名(或 DNS 缓存已过期),浏览器要现做 DNS 解析 + TCP 建连 + TLS 握手;跨公网、尤其移动网络 / 运营商 DNS 抖动时,这一步可能耗时 1 秒以上,用户会感觉“点了登录卡一下“。
在你的页面 <head> 加两行,让浏览器在用户浏览时就提前把这些做完:
<link rel="dns-prefetch" href="//auth.ai-as.cc">
<link rel="preconnect" href="https://auth.ai-as.cc" crossorigin>
dns-prefetch:提前解析auth.ai-as.cc的 DNSpreconnect:进一步提前完成 DNS + TCP + TLS 握手(比dns-prefetch更彻底)- 两者叠加,用户点登录时直接复用已建立的连接,省掉那次 1s。
加在哪:所有“有 AuthKeystone 登录入口“的页面 <head>,或全局 layout 的 <head> 里。
有没有副作用:没有。纯性能优化,浏览器空闲时执行,不阻塞页面渲染、不影响功能和安全——加了只有好处,建议各业务都加。
2.4 登出 / 单点登出(SLO,必须做)
⚠️ 务必做:业务应用 logout 时,必须让浏览器跳到 AuthKeystone 的 /oidc/end_session 清掉 AuthKeystone 侧的 SSO 会话。只清业务应用自己的 session 不够。
为什么必须做:首次登录后 AuthKeystone 会在浏览器种一个 HttpOnly 的 auth_session SSO cookie(存活到 access_token 过期,默认 24h)。业务 logout 若不调 end_session:
auth_sessioncookie 残留在浏览器- 用户再点登录 →
/authorize读到 cookie 有效 → 免密直接发 code(“自动登录”) - token 未过期(24h 内)就一直免密;过 24h 才重新要密码——这就是“有时候自动登录“的根因
怎么调(业务 logout 最后一步,浏览器顶层跳转):
https://auth.ai-as.cc/oidc/end_session?
id_token_hint=<登录时拿到的 id_token>
&post_logout_redirect_uri=<登出后回业务应用的地址>
| 参数 | 必填 | 作用 |
|---|---|---|
id_token_hint | 推荐 | 登录时 /oauth/token 返回的 id_token。带它,auth 撤销该用户 access/refresh token + 删 session;不带只清 cookie |
post_logout_redirect_uri | 可选 | 登出后回业务应用的地址(须 http(s)://) |
- 必须
window.location浏览器跳转,不能fetch——跨域请求不带 auth.ai-as.cc 的 cookie,清不掉auth_session。 - 标准库可自动:发现文档已声明
end_session_endpoint,标准 OIDC SDK(Nodeopenid-client/ Pythonauthlib/ Javapac4j)发现后会自动在 logout 调用,通常无需手写。
2.5 验 token(JWKS 本地验签,推荐)vs introspect(实时查)
- 本地验签(推荐):用
/.well-known/jwks.json的 RS256 公钥本地验 token 签名 + 校验aud=你的 client_id +exp未过期。无状态,高性能。 - introspect(备选):
POST /oauth/introspect实时查 token 有效性 + 权限。适合需要 token 实时状态(如已吊销)的场景。
3. OIDC 端点速查
| 端点 | 用途 |
|---|---|
GET /.well-known/openid-configuration | OIDC 发现文档(所有端点 + 支持的 scope/claim/grant) |
GET /.well-known/jwks.json | RS256 验签公钥(本地验 token) |
GET /authorize | 授权码签发(用户登录入口) |
POST /oauth/token | 换 token(authorization_code / password / refresh_token / client_credentials) |
GET /api/userinfo | 用户信息(Bearer token → 标准 OIDC claims) |
POST /oauth/introspect | token 内省(实时查有效性 + 权限) |
POST /oauth/revoke | token 吊销 |
GET /oidc/end_session | 单点登出(SLO) |
4. token claim 详解
核心层(所有 token 必有,OIDC 协议级)
{
"sub": "1", // user_id(字符串),用户唯一稳定主键
"iss": "https://auth.ai-as.cc",
"aud": "your_client_id", // 你的 client_id(验 token 时校验)
"exp": 1735689600,
"iat": 1735603200,
"jti": "unique-token-id"
}
扩展层(按 scope 暴露)
{
"preferred_username": "zhangsan", // scope=profile
"email": "zs@example.com", // scope=email
"email_verified": true,
"org_id": "default", // scope=auth:org(个人场景别要这个)
"permissions": ["wiki:read", "wiki:write"] // scope=auth:permissions
}
5. 关键规范(务必遵守)
- 用
sub做用户主键——这是 OIDC 标准,跨组织/改名/换租户都稳定。知识库、配额、会话、偏好,全用它。 org_id仅用于团队/企业隔离——做团队空间、企业数据隔离。个人场景(每个用户独立空间)用sub,不要org_id。- 验 token 时校验
aud= 你的 client_id——防止别的业务的 token 被你误用。 - 只声明你需要的 scope——最小暴露,AuthKeystone 会追踪谁用了哪些字段(迭代时评估影响)。
- 业务自定义 scope 走申请制——向 auth SaaS 管理员申请(如
exomind:wiki),不要擅自用未注册的 scope。 - logout 必须走 SLO——业务 logout 跳
/oidc/end_session(带id_token_hint),否则 AuthKeystone 的 SSO cookie 残留,用户 24h 内点登录会免密“自动登录“。详见 2.4。
6. API Key(服务间,machine-to-machine)
如果你的服务是后端调用 auth(无用户交互),用 API Key:
- 在 AuthKeystone 后台创建 API Key(绑权限范围 + 可选 account_key 资源隔离)。
- 调用时
Authorization: Bearer sk_xxx或X-API-Key: sk_xxx。 POST /api/keys/validate验证 key + 拿权限。
7. 常见错误(避坑)
| 错误 | 后果 | 正确 |
|---|---|---|
用 org_id/tenant_id 做用户主键 | 串用户(default 下所有人共享一个 key) | 用 sub |
用 username 做主键 | 改名后身份断裂 | 用 sub(user_id 稳定) |
不校验 aud | 别的业务 token 被你误用 | 验 aud = 你的 client_id |
声明 auth:org scope 但个人场景 | 多此一举(拿到 org_id=default 没用) | 个人场景只声明 openid+profile |
| token 当永久 session | 权限变更后不生效 | token 有 exp,过期 refresh;或用 introspect 实时查 |
| logout 不调 /oidc/end_session | AuthKeystone 的 SSO cookie 残留,用户 24h 内点登录免密自动登录 | logout 末尾浏览器跳 end_session(带 id_token_hint),见 2.4 |
8. 联调检查清单
下游接入联调时逐项确认:
- OAuth client 注册(client_id/secret/redirect_uri/grant_types)
- scope 声明(勾选你需要的)
- 授权码流程跑通(/authorize → callback → /oauth/token)
- token 验签(JWKS 本地 + aud 校验)
- sub 读法正确(= user_id 字符串,做用户主键)
- /userinfo 调通(按 scope 返 claim)
- refresh_token 续期
- 登录时保存 id_token(供 logout 作 id_token_hint)
- 登出走 SLO(/oidc/end_session,带 id_token_hint)——必须,否则 SSO cookie 残留致免密自动登录(见 2.4)
数据权限设计文档 (Data Authorization)
本文档说明 AuthKeystone 的权限模型如何在代码层面落地,以及各类数据的隔离边界。 适合开发者、架构师、运维理解“谁能看到什么、谁不能看到什么“。
一、权限模型总览
系统有 两层权限,它们独立运作:
┌─────────────────────────────────────────────────────────┐
│ 第一层:功能权限(RBAC — 你能做什么操作) │
│ │
│ 用户 → 角色 → 菜单(F=按钮权限) │
│ 例:admin 角色 → system:user:list(能看用户列表) │
│ 代码落地点:middleware::check_permission() │
├─────────────────────────────────────────────────────────┤
│ 第二层:数据权限(Data Scope — 你能看到哪些人的数据) │
│ │
│ 角色.data_scope 字段:self / department / all │
│ 例:数据范围=self → 只能看到自己的用户记录 │
│ 代码落地点:admin::enforce_data_scope() + list_users │
└─────────────────────────────────────────────────────────┘
两者的关系:先过功能权限(能不能做),再过数据权限(对谁做)。
例:你有 system:user:list(功能权限),但 data_scope=self,那用户列表里只有你自己。
二、第一层:功能权限(RBAC)
数据模型
users ──< user_roles >── roles ──< role_menus >── menus
│
└── data_scope (数据权限,见第三节)
- menus 表:目录(M)、菜单(C)、按钮(F)三级树。F 节点带
permission字段(如system:user:list)。 - roles 表:角色,绑定一组菜单(F 权限)。
- user_roles 表:用户-角色关联(多对多)。
权限标识规范
格式:模块:资源:操作,例如:
system:user:list— 系统模块/用户/查询system:user:create— 系统模块/用户/创建user:apikey:list— 普通用户域/密钥/查询(与 admin 的 system:apikey:list 分离,避免 UNIQUE 冲突)
代码落地
签发时:JWT 的 permissions 字段携带用户所有 F 类权限。
#![allow(unused)]
fn main() {
// auth.rs — login/issue_tokens_for_user_id
let user_permissions = self.get_user_permissions(user.id).await?;
// get_user_permissions → SELECT DISTINCT m.permission FROM role_menus...
// 结果存入 claims.permissions
}
验证时:中间件从请求提取 token → 验签 → 注入 Claims 到请求扩展。
#![allow(unused)]
fn main() {
// middleware.rs — auth_middleware
let verified = auth_service.verify_token_full(token).await?;
req.extensions_mut().insert(verified.claims); // 后续 handler 通过 Extension<Claims> 取
}
检查时:handler 调用 check_permission:
#![allow(unused)]
fn main() {
// middleware.rs
pub fn check_permission(claims: &Claims, permission: &str) -> Result<(), AppError> {
if claims.permissions.contains(&SUPER_PERMISSION.to_string()) { return Ok(()); } // * 通配
if claims.permissions.contains(&permission.to_string()) { return Ok(()); }
Err(AppError::Permission(format!("权限不足:需要 {}", permission)))
}
}
OR 逻辑(同一操作多权限域):
#![allow(unused)]
fn main() {
// middleware.rs — check_any_permission
// 用于 apikeys:admin 用 system:apikey:list,普通用户用 user:apikey:list
pub fn check_any_permission(claims: &Claims, permissions: &[&str]) -> Result<(), AppError>
}
实时刷新:verify_token_full 时从 DB 重新查权限覆盖 JWT 中的值(角色变更后立即生效,不依赖 token 重新签发):
#![allow(unused)]
fn main() {
// auth.rs — verify_token_full
let fresh_permissions = self.get_user_permissions(claims.user_id).await?;
claims.permissions = fresh_permissions.clone(); // 覆盖
}
三、第二层:数据权限(Data Scope)
三种数据范围
| data_scope | 含义 | list_users 行为 | enforce_data_scope 行为 |
|---|---|---|---|
self | 仅本人 | WHERE id = ?(当前用户) | 操作目标必须是自己 |
department | 本部门 | WHERE dept_id = (操作者的 dept_id) | 操作目标必须同部门 |
all | 全部 | 无 WHERE 限制 | 放行 |
取最宽原则:用户有多个角色时,取最宽的 data_scope(all > department > self)。
#![allow(unused)]
fn main() {
// admin.rs — get_user_data_scope
async fn get_user_data_scope(state: &AppState, user_id: i64) -> String {
// 查该用户所有角色的 data_scope
// all > department > self(取最宽)
}
}
数据范围仅作用于“用户管理“
当前数据权限只影响用户管理模块(list_users / update_user / delete_user):
all:看到/操作所有用户self:只看到/操作自己department:看到/操作同部门用户
其它模块(角色/菜单/字典/部门/审计)不受数据范围限制——它们是系统级配置,有功能权限即可访问全部。
设计理由:角色/菜单/字典是全局配置数据,没有“我的角色 vs 你的角色“概念。只有用户数据有归属(属于谁/哪个部门),需要数据范围。
代码落地
list_users(查询过滤):
#![allow(unused)]
fn main() {
// admin.rs — list_users
let data_scope = get_user_data_scope(&state, claims.user_id).await;
let rows = if data_scope == DATA_SCOPE_ALL {
// 全部用户
sqlx::query("SELECT ... FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?")
} else {
// 仅本人(department 暂未在 list_users 实现,降级为 self)
sqlx::query("SELECT ... FROM users WHERE id = ? LIMIT ? OFFSET ?").bind(claims.user_id)
};
}
enforce_data_scope(单条操作拦截):
#![allow(unused)]
fn main() {
// admin.rs — update_user / delete_user 调用
async fn enforce_data_scope(state: &AppState, claims: &Claims, target_user_id: i64) -> Result<()> {
let scope = get_user_data_scope(state, claims.user_id).await;
match scope.as_str() {
"all" => Ok(()),
"department" => {
// 比较操作者与目标的 dept_id
let my_dept = sqlx::query_scalar("SELECT dept_id FROM users WHERE id = ?")...;
let target_dept = sqlx::query_scalar("SELECT dept_id FROM users WHERE id = ?")...;
if my_dept == target_dept { Ok(()) } else { Err(权限不足) }
}
_ => { // self
if claims.user_id == target_user_id { Ok(()) } else { Err(权限不足) }
}
}
}
}
四、不同来源用户的权限差异
| 来源 | 默认角色 | 默认 data_scope | 可见数据 | 内置 admin 保护 |
|---|---|---|---|---|
| 内置 admin | admin 角色(种子) | all | 全部 | ✓ 禁用/删除/去角色均被拒 |
| admin 手动创建 | 创建时指定 | 创建时指定 | 取决于角色 | 仅 admin 账户受保护 |
| 自助注册 | user 角色(种子) | self | 仅自己 | 普通用户 |
| GitHub OAuth 首次 | user 角色(种子) | self | 仅自己 | 普通用户 |
关键点:
- 注册/GitHub 用户自动获得 user 角色,data_scope=self,只能看自己的记录。
- 管理员可在用户管理里给这些用户分配更高级角色(data_scope=all 的角色)来提升权限。
- admin 账户(username=admin)有三重保护:禁止禁用、禁止删除、禁止移除管理员角色。
注册用户如何与 admin 交互(闭环)
用户注册/GitHub登录
→ 自动获得 user 角色(data_scope=self)
→ 看到自己的用户记录(用户列表只显示自己)
→ admin 在用户管理里看到该用户(admin 的 data_scope=all)
→ admin 给该用户分配新角色(如"运营",data_scope=department)
→ 该用户重新登录/刷新后生效(权限实时刷新)
→ 该用户现在能看到本部门所有用户
五、哪些数据隔离,哪些不隔离
| 数据 | 是否隔离 | 原因 |
|---|---|---|
| 用户数据 | ✓ 按 data_scope 隔离 | 用户有归属(自己/部门),需要范围控制 |
| 角色 | ✗ 不隔离 | 系统级配置,有 system:role:list 权限即可看全部 |
| 菜单 | ✗ 不隔离 | 系统级配置,全局唯一 |
| 字典 | ✗ 不隔离 | 枚举值,全局共享 |
| 部门 | ✗ 不隔离 | 组织结构,全局唯一 |
| 审计日志 | ✗ 不隔离 | 有 system:audit:list 即可看全部(安全合规需要) |
| API Key | 部分 | admin 看全部 Key;普通用户只看自己的(通过不同权限域区分) |
为什么不引入租户隔离?
当前是全局单租户模型(所有用户在同一 users 表,共享同一套配置)。原因:
- 本系统是内部中台(为微服务提供认证授权),不是面向 C 端的多租户 SaaS。
- 用户量可控,不需要租户间的数据强隔离。
- 如果未来需要多租户,可以在此基础上加
tenant_id维度(users 表已有 tenant_id 字段)。
六、已知限制与后续计划
-
department 数据范围在 list_users 未完全实现:当前 list_users 只有 all/self 两个分支,department 降级为 self。后续需补 department 分支(
WHERE dept_id = 操作者的 dept_id)。 -
数据范围只作用于用户管理:角色/菜单等系统配置不受 data_scope 影响。如果业务需要“角色也按部门隔离“,需扩展 enforce_data_scope 到这些模块。
-
API Key 没有数据范围:API Key 的权限通过
permissions数组控制,不涉及 data_scope。资源隔离通过account_key实现(多租户维度)。 -
菜单/角色无租户隔离:所有用户共享同一套菜单树和角色定义。如需“不同租户看到不同菜单“,需加 tenant_id 到 menus/roles 表。
附:权限检查代码索引
| 功能 | 代码位置 | 说明 |
|---|---|---|
| 功能权限检查 | middleware::check_permission | 单权限检查 |
| 多权限检查(OR) | middleware::check_any_permission | apikeys 等多权限域 |
| 数据范围获取 | admin::get_user_data_scope | 查用户角色的最宽 data_scope |
| 数据范围拦截 | admin::enforce_data_scope | update/delete 操作目标校验 |
| 查询过滤 | admin::list_users | 根据 data_scope 加 WHERE |
| 权限实时刷新 | auth::verify_token_full | 验签时从 DB 重查权限覆盖 JWT |
| admin 保护 | admin::update_user/delete_user | 禁止禁用/删除 admin |
| 角色移除保护 | handlers::assign_roles | 禁止移除 admin 的管理员角色 |
GitHub OAuth 配置指南
⚠️ 已退役(2026-07):硬编码的
/api/auth/github*//api/auth/gitee*路由与.env的GITHUB_*/GITEE_*配置已移除。 GitHub/Gitee 登录现统一走配置化 IdP(tenant_oidc_providers表 +/api/auth/sso/*路径)。 配置方式见docs/TENANT_UNIFIED_IDP.md与「SSO 配置」管理页。 本文档保留作历史参考(GitHub OAuth App 创建步骤仍适用,凭据改为填入 SSO 配置页的 OAuth2 IdP 表单)。
GitHub 第三方登录需要创建一个 GitHub OAuth App 并配置凭据。
步骤
1. 创建 GitHub OAuth App
- 打开 https://github.com/settings/developers
- 点击 New OAuth App
- 填写:
- Application name:
Auth Service(或你的应用名) - Homepage URL:
https://www.ai-as.cc - Authorization callback URL:
https://auth.ai-as.cc/api/auth/github/callback
- Application name:
- 点击 Register application
2. 获取凭据
创建后页面显示:
- Client ID: 复制保存(形如
Iv1.1234abcd...) - Client Secret: 点击
Generate a new client secret,复制保存(只在此时显示一次)
3. 配置到服务器
在 ECS 上编辑 /opt/auth-service/.env,添加:
GITHUB_CLIENT_ID=你的Client ID
GITHUB_CLIENT_SECRET=你的Client Secret
GITHUB_CALLBACK_URL=https://auth.ai-as.cc/api/auth/github/callback
然后重启服务:
systemctl restart auth-service
4. 验证
打开 https://auth.ai-as.cc/admin/ → 登录页应显示 “GitHub 登录” 按钮 → 点击跳转到 GitHub 授权 → 授权后自动回到管理后台。
工作原理
用户点击"GitHub登录"
→ GET /api/auth/github(后端生成 state,跳转 GitHub 授权页)
→ 用户在 GitHub 授权
→ GitHub 回调 GET /api/auth/github/callback?code=xxx&state=xxx
→ 后端用 code 换 access_token → 获取 GitHub 用户信息
→ 首次登录自动创建用户(用户名 = GitHub 用户名)
→ 分配默认 user 角色
→ 签发 JWT token,重定向回前端
→ 前端从 URL 参数读 token,写入登录态
故障排查
| 问题 | 原因 | 解决 |
|---|---|---|
| “GitHub OAuth 未配置” | .env 里 GITHUB_CLIENT_ID 为空 | 按上述步骤配置 |
| 回调页报错 | callback URL 不匹配 | 确认 GitHub App 里的 callback = https://auth.ai-as.cc/api/auth/github/callback |
| 登录后空白页 | token 回传前端失败 | 检查 OIDC_ISSUER 是否 = https://auth.ai-as.cc |
IDaaS 身份标准化终态方案
auth 作为 OIDC 基础服务,给下游业务(ExoMind/wechat/…)提供身份。本文是终态方案——抓住当前仅 2 个下游的窗口期,一次性落地,零过渡债。 定稿 2026-07-11。决策经评审确认:①一步到位不分阶段 ②tenant→org 彻底 rename ③scope 系统预置+业务申请制。
0. 背景与根因
ExoMind wiki 空白:下游把 auth 的 tenant_id(SaaS 租户)误用为用户身份做个人知识库 key。深层根因不是 tenant_id 的错,而是 auth 没给下游「标准、稳定、明确」的用户身份字段——sub 填的是 username(可变,下游不敢当主键)、user_id 是非标准字段名。下游找不到可信标识 → 乱抓字段 → 抓到 tenant_id → 串用户。
这是 IDaaS 身份语义的系统性问题。当前仅 2 个下游(ExoMind/wechat),是建标准的黄金窗口——一次终态,成本最低,未来 N 个下游时不还债。
1. 业界调研结论(Auth0/Keycloak/Azure 共性)
| 维度 | 业界做法 | 来源 |
|---|---|---|
| 字段暴露 | scope 驱动 claim(openid/profile/email/业务 scope 各对应一组 claim) | Auth0 Actions、Keycloak client scopes、Azure optional claims |
| per-client 配置 | 每个 client 配自己的 scope/claim(Keycloak client scopes / Auth0 Actions 按 client_id / Azure app registration) | scope vs roles |
| 多租户 | 单部署多 Organization(逻辑隔离),趋势替代每租户独立 realm | Auth0 Organizations、Keycloak 26+ Organizations |
| 用户标识 | sub = user_id(OIDC 标准,稳定唯一) | Auth0/Keycloak/Azure 一致 |
最稳定的锚点:OIDC 标准 claim(sub/iss/aud/exp/iat/jti)+ scope→claim 映射 + per-client 配置 + Organization 多租户。抓住这五点,十年不变。
2. 四层正交模型(架构地基)
业界能长期稳定,核心是四层解耦,互不污染:
身份(Who) sub = user_id 人/服务的唯一标识,跨组织/时间稳定(改名/换公司都不变)
组织(Whose) org_id 身份的分组容器(个人/团队/企业),可变、可多属
权限(What) permissions+scope 能做什么(RBAC 权限 + 客户端范围)
客户端(Which)client_id (aud) 哪个应用在请求,决定可见字段 + 权限边界
铁律:四者正交。token 是它们在「某次请求」的投影——按 client(aud) + 用户请求的 scope,投影出该次该下游该看到的字段。任一层变化不污染其他层。
ExoMind 错误的本质:把第二层(组织 org_id)当第一层(身份 sub)用——层级错位。根治 = 让 sub 标准可靠 + 用 scope 让它根本看不到 org_id。
3. 终态决策(已确认)
| 决策 | 选择 | 理由 |
|---|---|---|
| 落地节奏 | 一步到位(不分阶段、无 sub_uid 过渡) | 仅 2 下游联调成本极低;中间态无架构价值,只增维护成本与割接风险 |
| tenant→org | 彻底 rename(DB 列 + 代码,不只文档) | 一次终态不存历史错误语义;根除「tenant=用户身份」误导;改动量小,技术债清零 |
| scope 治理 | 系统预置 + 业务申请制 + 两表追踪 | 系统级(openid/profile/email/auth)统一预置保证标准;业务自定义走申请制,可审计可追踪可灰度下线 |
4. token 设计(四层投影)
核心层(所有 token 必有,OIDC 协议级,不可删)
sub(user_id) iss aud(client_id) exp iat jti
扩展层(按 scope 暴露,per-client 配置)
| scope | claim | 谁该要 |
|---|---|---|
openid(默认) | sub | 所有下游 |
profile | preferred_username, name, picture | 要展示用户名的 |
email | email, email_verified | 要邮箱的 |
auth:permissions | permissions[] | 要做 RBAC 授权的 |
auth:org | org_id(当前组织) | 要做团队/企业隔离的(ExoMind 个人 wiki 不要这个 → 拿不到 org_id → 没机会误用) |
业务自定义(exomind:wiki 等) | 业务约定 claim | 特定业务,申请制 |
关键字段语义(写进 INTEGRATION.md,下游契约)
sub= 用户身份主键(下游用它做用户级隔离:个人知识库、配额、偏好)。稳定唯一,跨组织不变。org_id= 组织容器(团队/企业数据隔离)。不是用户身份。个人场景不需要。aud= 目标客户端(下游验 token 时校验 aud == 自己 client_id,防跨业务误用)。
5. 数据模型
5.1 tenant → org 彻底 rename(DB 列 + 代码)
所有表的 tenant_id 列 → org_id:
users.org_id、roles.org_id、api_keys.org_id、audit_logs.org_id、departments.org_id、oauth_clients.org_id、tenant_oidc_providers→org_idp_providers(org 级 IdP)。tenants表 →organizations(org 主表)。- 代码:
tenant_id→org_id(models/middleware/handlers/admin/sso/tenant.rs→org.rs 全局,grep 替换 + 编译引导)。 - 当前数据:default org(个人),无企业。
5.2 client_scopes + oauth_client_scopes(新增,per-client + 追踪)
-- 可复用 scope 定义(scope → claim 映射束,Keycloak client scope 模式)
CREATE TABLE client_scopes (
id INTEGER PRIMARY KEY,
scope VARCHAR(50) UNIQUE NOT NULL, -- openid/profile/email/auth:permissions/auth:org/exomind:wiki
description VARCHAR(200),
claims TEXT NOT NULL DEFAULT '[]', -- JSON: ["preferred_username","name","picture"]
is_system BOOLEAN DEFAULT FALSE, -- 系统内置不可删
created_at, updated_at
);
-- 系统 scope 预置(seed):openid(→sub) / profile / email / auth:permissions / auth:org
-- 每 OAuth client 启用哪些 scope(per-client 配置 + 追踪依据)
CREATE TABLE oauth_client_scopes (
client_id VARCHAR(64) NOT NULL, -- oauth_clients.client_id
scope VARCHAR(50) NOT NULL,
PRIMARY KEY(client_id, scope)
);
追踪查询(「谁在用哪些字段」+ 影响评估):
SELECT cs.scope, cs.claims, GROUP_CONCAT(ocs.client_id) AS downstream
FROM client_scopes cs JOIN oauth_client_scopes ocs ON cs.scope=ocs.scope
WHERE cs.claims LIKE '%email%' GROUP BY cs.scope;
6. 实施 plan(代码级,一次发版)
模块 1:身份标识标准化(治本)
src/auth.rsClaims:sub= user_id(string);加preferred_username;加aud= client_id。- 所有签 token 处(login/register/refresh/OAuth grant/SSO callback)填新 claims。
username从 sub 移到 preferred_username(OIDC 标准 claim)。
模块 2:tenant → org 彻底 rename
- DB 迁移(db.rs):所有表
tenant_id列 renameorg_id(SQLite 不支持直接 rename column,用「新建列 + 迁数据 + 旧列留空兼容」或重建表;或若可接受,保留列名 tenant_id 但代码层全用 org_id——决策:彻底 rename,重建表迁移)。 tenants→organizations表 rename。- 代码全局
tenant_id→org_id:models.rs / middleware.rs(RequestMeta) / admin.rs / handlers.rs / sso.rs / rbac.rs / tenant.rs→org.rs / oauth_client.rs。 - admin-ui:
tenant文案 →组织(tenant_id → org_id 字段)。
模块 3:scope + per-client claim 注入
- db.rs:建
client_scopes+oauth_client_scopes表 + seed 系统 scope。 - models.rs:ClientScope / OAuthClientScope struct。
- 签 token 逻辑:按 client(aud) 启用的 scope ∩ 用户请求 scope → 注入对应 claim(核心层始终有 + 扩展层按 scope)。
- admin.rs:scope CRUD(系统 scope 不可删,业务 scope 申请制)+ client scope 配置 API。
- admin-ui:oauth_clients 配置页加「scope 配置」+ 「下游字段用量」追踪视图。
模块 4:/userinfo + /oauth/token 规范化
/userinfo返 sub(user_id) + 按 scope 的 claims(标准 OIDC userinfo)。/oauth/token响应 token 含 aud + 按 scope。- OIDC discovery(/.well-known)scopes_supported 列系统 scope。
模块 5:INTEGRATION.md 下游接入规范
新建 docs/INTEGRATION.md:
- 下游注册 OAuth client + 声明 scope。
- 用 sub 做用户主键(标准)。
- org_id 仅团队/企业隔离(与 sub 正交)。
- 验 token 校验 aud。
- scope 申请流程。
模块 6:下游联调
- ExoMind:改读 sub(user_id) 做知识库 key;声明 scope = openid + exomind:wiki(不要 auth:org)。
- wechat:评估 sub 变化(username→user_id)影响 + 声明 scope。
7. 文件影响清单
| 文件 | 改动 |
|---|---|
| src/auth.rs | Claims(sub/aud/preferred_username) + 签 token |
| src/models.rs | Claims/User/Role/… tenant_id→org_id + ClientScope/OAuthClientScope |
| src/middleware.rs | RequestMeta tenant_id→org_id |
| src/db.rs | 表列 rename org_id + organizations + client_scopes + oauth_client_scopes + seed |
| src/admin.rs | tenant→org + scope CRUD + client scope 配置 |
| src/handlers.rs | tenant→org |
| src/sso.rs | tenant→org |
| src/tenant.rs → src/org.rs | rename + tenant→org |
| src/oauth.rs | aud + scope 注入 claim |
| src/oauth_client.rs | tenant→org |
| admin-ui/* | tenant→org 文案 + scope 配置 UI + 追踪视图 |
| docs/INTEGRATION.md | 新建下游接入规范 |
8. 风险 + 回滚
| 风险 | 应对 |
|---|---|
| tenant→org rename 全局(编译错引导,但量大) | grep tenant 全替换 + cargo check 迭代;编译保证不漏 |
| sub 从 username→user_id 破坏下游缓存 | 仅 2 下游,联调前通知;过渡期 sub 同时含 user_id(旧下游读 username 的会拿到 user_id 字符串,需适配)—— 但决策是不过渡,直接切,下游联调 |
| DB 列 rename(SQLite 限制) | 重建表迁移(CREATE org_id 列 + COPY + 旧表留或 DROP);或在代码层 rename(DB 列名暂留 tenant_id,代码用 org_id)—— 决策:彻底 rename(含 DB),用重建表迁移 |
| 下游联调不顺 | ExoMind + wechat 同步改 + 联调;auth 提供 /userinfo 兜底(下游可实时查) |
回滚:若联调失败,回退 commit(DB rename 不可逆,需备份;或在 rename 前完整备份 auth.db)。
9. 工作量(一次发版)
- 模块 1(身份):~2 天
- 模块 2(rename):~3 天(全局 grep + DB 迁移)
- 模块 3(scope/per-client):~3 天(表 + 注入逻辑 + UI)
- 模块 4(/userinfo/token 规范):~1 天
- 模块 5(INTEGRATION.md):~0.5 天
- 模块 6(下游联调):~1-2 天
- 合计 ~10-11 天(2 周),一次发版。
10. 下游接入新规范(契约,写进 INTEGRATION.md)
- 注册 OAuth client(client_id/secret/redirect_uri/scopes)。
- 声明用哪些 scope(auth 配置 client_scopes)——「我需要哪些字段」的契约。
- 用
sub做用户主键(标准,跨组织稳定)—— 绝不用 org_id 当用户身份。 - org_id 只用于团队/企业隔离(若需要)—— 与 sub 正交。
- 验 token 校验 aud(= 自己 client_id)—— 防 token 跨业务误用。
- 业务自定义 scope 走申请制(向 SaaS 申请,可审计)。
多租户地基(阶段 1)实施方案
auth(OIDC + RBAC,Rust/Axum + SQLite/Redis)产品化为多租户 IDaaS SaaS 的第 1 阶段。 目标规模:中等(多租户 SaaS,万级用户)。整体路线见文末「后续阶段」。 本文档为可追溯的设计与实施依据,定稿于 2026-07-08。
1. 阶段 1 目标与边界
目标:在单库(SQLite)上跑通「一个租户上下文贯穿所有 handler」——所有业务数据按租户隔离,平台管理员可跨租户。最小可验证里程碑。
不做(明确边界):
- ❌ 不换 PostgreSQL(阶段 2)
- ❌ 不做租户管理 UI / 自助 / 配额计费(阶段 3 / 5)
- ❌ 不做
menus/dict_*多租户(平台共享) - ❌ 不做 RLS(SQLite 没有,靠代码 + 跨租户测试兜底;阶段 2 上 PostgreSQL 再加 RLS 做硬兜底)
- ❌ 前端最小化(阶段 3 做租户切换器)
成功标准:升级零破坏(现有数据归 default 租户,功能不变)+ 跨租户隔离测试通过。
2. 现状摸底
auth 早就有 tenant_id 雏形,但语义是「每用户一个独立租户」,不是真正的多用户共享租户:
| 已有 | 位置 | 现状 |
|---|---|---|
users.tenant_id 列 | db.rs:61,含迁移 db.rs:688-704 | 值 = username(每用户一租户) |
JWT Claims.tenant_id | auth.rs:24 | 登录时从 user 带出 |
TokenInfo.tenant_id | models.rs:119 | 同上 |
登录 tenant_id = COALESCE(tenant_id, username) | handlers.rs:850 | 兜底为 username |
超级权限 admin:manage(SUPER_PERMISSION) | models.rs、middleware.rs:105 | 绕过所有权限检查 |
缺失:
- ❌ 无
tenants主表(租户注册/状态/配额) - ❌
roles/api_keys/audit_logs/departments/oauth_clients都没有tenant_id - ❌
menus/dict_type/dict_data未定性(平台共享 or 租户独立) - ❌ 无
TenantContext中间件(tenant_id在 Claims 里但没用于数据隔离)
阶段 1 = 改造现有雏形语义 + 补齐缺失。
3. 设计决策
3.1 哪些资源按租户隔离
| 资源 | 加 tenant_id? | 理由 |
|---|---|---|
users | ✓(改语义) | 已有列,值从 “=username” 改为 “=租户标识” |
roles departments api_keys audit_logs oauth_clients | ✓ 新增 | 每租户独立 |
user_roles role_menus | 隐含 | 经 user→tenant、role→tenant 链隔离,不重复加列 |
menus dict_type dict_data | ✗ 平台共享 | 权限/字典是平台级模板,所有租户共用 |
3.2 tenant_id 类型与值
- 类型:
VARCHAR(50)(与现有users.tenant_id一致) - 值:可读标识,如
"default"、"acme"、"xyz" - 主表:
tenants(id, name, status, max_users, ...)
3.3 default 租户
现有全部数据归入 "default" 租户,保证升级零破坏:
users.tenant_id:UPDATE users SET tenant_id='default' WHERE tenant_id IS NULL OR tenant_id=username- 其他业务表:
ALTER ADD COLUMN tenant_id+UPDATE ... SET tenant_id='default' tenants表插入('default', '默认租户')
4. 平台 admin vs 租户 admin
多租户系统两层管理员,权限范围完全不同:
平台运营方(我们)
└─ 平台管理员(platform admin) ← 看所有租户、建/冻结租户、全局审计
↓ tenant_id 隔离
租户 acme
└─ 租户管理员(acme 的 IT) ← 只管 acme,看不到 xyz
↓
租户 xyz
└─ 租户管理员(xyz 的 IT) ← 只管 xyz
复用 admin:manage 作为「平台管理员」标识
auth 已有超级权限 admin:manage(SUPER_PERMISSION):持有人绕过所有权限检查(middleware.rs:105)。现 admin 用户即持有它。
多租户后,这个超级权限的自然延伸就是「平台管理员」:
| 谁 | 持 admin:manage? | 中间件算出的 tenant 作用域 | list_users 看到 |
|---|---|---|---|
admin(平台运营) | ✓ | None(不过滤) | 所有租户的用户 |
acme_admin(acme 的 IT) | ✗ | Some("acme") | 只有 acme 的用户 |
xyz_user(xyz 普通员工) | ✗ | Some("xyz") | 只有 xyz 的用户(且受 RBAC 约束) |
一句话:
admin:manage= 平台管理员 = 不受租户隔离 = 跨租户看全部;没有它就是租户用户 = 被自己的 tenant_id 锁住。
后续(阶段 3)若要更清晰区分「平台运营」与「租户管理员」,可新增 platform:admin 权限,但阶段 1 复用 admin:manage 即可,避免引入新概念。
5. 数据模型变更(src/db.rs)
5.1 新建 tenants 主表
CREATE TABLE IF NOT EXISTS tenants (
id VARCHAR(50) PRIMARY KEY, -- 'default', 'acme'
name VARCHAR(100) NOT NULL,
status CHAR(1) DEFAULT '0', -- 0=正常 1=冻结
max_users INTEGER DEFAULT 0, -- 配额(0=不限,阶段 5 启用)
created_at DATETIME DEFAULT (datetime('now','+8 hours')),
updated_at DATETIME DEFAULT (datetime('now','+8 hours'))
);
INSERT OR IGNORE INTO tenants (id, name) VALUES ('default', '默认租户');
5.2 业务表加 tenant_id(增量迁移)
通用模式(参考 db.rs:688 的 pragma_table_info 检测),对 roles / api_keys / audit_logs / departments / oauth_clients 各执行:
#![allow(unused)]
fn main() {
let has: bool = sqlx::query_scalar::<_, i64>(
&format!("SELECT COUNT(*) FROM pragma_table_info('{tbl}') WHERE name='tenant_id'")
).fetch_one(pool).await? > 0;
if !has {
sqlx::query(&format!("ALTER TABLE {tbl} ADD COLUMN tenant_id VARCHAR(50)")).execute(pool).await?;
sqlx::query(&format!("UPDATE {tbl} SET tenant_id='default' WHERE tenant_id IS NULL")).execute(pool).await?;
sqlx::query(&format!("CREATE INDEX IF NOT EXISTS idx_{tbl}_tenant ON {tbl}(tenant_id)")).execute(pool).await?;
}
}
5.3 users.tenant_id 语义迁移(关键)
UPDATE users SET tenant_id='default' WHERE tenant_id IS NULL OR tenant_id=username;
把「每用户一租户」折叠回 default。实施前必须 grep 全部 tenant_id 用法逐一 review(重点 handlers.rs:850 的 COALESCE、OIDC claim 里的 tenant_id)。
6. 后端改造
6.1 TenantContext(src/middleware.rs)
auth_middleware 验完 token 拿到 Claims 后,算 tenant 作用域注入 RequestMeta:
#![allow(unused)]
fn main() {
pub struct RequestMeta {
pub ip: Option<String>,
pub user_agent: Option<String>,
pub tenant_id: Option<String>, // None = 平台 admin(跨租户);Some(t) = 租户作用域
pub is_platform_admin: bool,
}
// auth_middleware 内:
let is_admin = claims.permissions.contains(&SUPER_PERMISSION.to_string());
let meta = RequestMeta {
ip, user_agent,
tenant_id: if is_admin { None } else { claims.tenant_id.clone() },
is_platform_admin: is_admin,
};
}
6.2 tenant_scope helper
#![allow(unused)]
fn main() {
/// 返回 WHERE 片段与 bind 值:None = 平台 admin 不过滤;Some = 租户作用域
fn tenant_scope(meta: &RequestMeta) -> Option<&str> {
meta.tenant_id.as_deref() // Some(t) → 加 "tenant_id = ?"; None → 不加
}
}
list/get 类查询按是否 Some 拼接 WHERE tenant_id = ?;INSERT 一律 bind tenant_id(平台 admin 创建资源时显式指定目标租户)。
6.3 改造清单(实施时 grep 定位)
grep -rn "FROM users\|FROM roles\|FROM api_keys\|FROM audit_logs\|FROM departments\|FROM oauth_clients" src/
| 文件 | 改造点 |
|---|---|
admin.rs | list_users / create_user / list_roles / create_role / api_keys CRUD / audit / departments / oauth_clients 全部加 tenant_scope + INSERT 带 tenant_id |
handlers.rs | 用户注册(已有 tenant_id 逻辑,改语义)、userinfo(OIDC claim tenant_id) |
rbac.rs | get_user_permissions / get_user_menu_tree 经 user→role→menu 链,roles 加 tenant_id 后天然隔离,SQL 无需改;但 assign_role 要校验 role.tenant_id == user.tenant_id |
api_keys.rs / oauth_client.rs | CRUD 加 tenant |
6.4 模型层(models.rs)
Role / ApiKey / OAuthClient / Department / AuditLog 加 pub tenant_id: String,与 SQL 对齐。
7. 前端(阶段 1 不做)
阶段 1 后端为主。前端最小化(现有 admin-ui 仍按平台 admin 用,看全部)。租户切换器、租户管理 UI 在阶段 3。
8. 验证(SQLite 无 RLS,靠测试兜底)
- 跨租户隔离:建 tenantA / tenantB + 各自用户/角色/apikey,登录 A 调 list 接口,断言看不到 B 的数据。
- 平台 admin 跨租户:admin 登录 list,看到所有租户数据。
- 升级兼容:default 租户用户登录、菜单、权限、OIDC 全部正常。
建议把跨租户隔离测试做成集成测试常驻,防止后续 handler 漏改。
9. 风险与回滚
| 风险 | 应对 |
|---|---|
users.tenant_id 语义从 username→default,影响 handlers.rs COALESCE 与 OIDC claim | 实施前 grep 全部 tenant_id 用法逐一 review;claim 里 tenant_id 改为 =default |
| handler 漏加 tenant_scope → 数据泄露 | 强制改造清单 + 跨租户集成测试;阶段 2 上 PostgreSQL RLS 硬兜底 |
| RBAC 链跨租户错配(user 拿到别租户 role) | assign_role 校验 role.tenant_id == user.tenant_id |
| 回滚 | 迁移只 ADD COLUMN + UPDATE,不动旧列;回滚 = 还原代码(列留着无害) |
10. 工作量
- db.rs 建表 + 5 表迁移 + users 语义迁移:~0.5 天
- 后端 handler 逐个加 tenant_scope(grep + 改):~2 天
- 模型 + helper + 中间件:~0.5 天
- 跨租户隔离测试:~1 天
- 合计 ~1 周(不含前端)
11. 后续阶段路线(追溯)
| 阶段 | 内容 | 产出 |
|---|---|---|
| 1. 多租户地基(本文档) | tenant_id + TenantContext + 业务表隔离,SQLite 先跑通 | 单库多租户 |
| 2. 换 PostgreSQL | sqlx 兼容迁移 + RLS 硬兜底 + 读写分离 + 数据搬迁 | 横扩就绪 |
| 3. 租户管理 + 自助 | tenant CRUD / 配额 / 品牌 / 登录页配置;admin-ui 多租户化 | 可对外卖 |
| 4. 横扩 + 可观测 | 多实例 + nginx LB + Prometheus / OpenTelemetry / tracing | 生产级 SLO |
| 5. 商业化 | 计费 / 用量统计 / 合规导出 / SSO 企业版 | 商业闭环 |
附:阶段 1 已确认的决策
- ✅
users.tenant_id全部折叠到"default"单租户(不保留「每用户一租户」) - ✅ 平台管理员复用现有
admin:manage超级权限(= 跨租户),不新增概念 - ✅
menus/dict_*平台共享,不加 tenant_id - ✅
user_roles/role_menus隐含隔离(经 role→tenant 链),不加列
统一 IdP 抽象(OIDC + OAuth2)
把 Inbound OIDC 的
tenant_oidc_providers扩展成「外部身份提供方」统一抽象,让租户在 UI 上自助配置任意登录源——既支持标准 OIDC IdP(Keycloak/Azure/Google),也支持 OAuth2 提供方(Gitee/GitHub/微信/飞书等非标准 OIDC),并按租户停用。 定稿 2026-07-10。前置:Inbound OIDC(docs/TENANT_PHASE5_INBOUND_OIDC.md)、Gitee 硬编码(commit1a058c7)。
0. 为什么
当前 auth 有两套登录入口:
- 硬编码 handler:GitHub、Gitee(每家一个 handler + 全局 client_id/secret,所有租户共享,租户无法停用/自定义)。
- 配置化 IdP:Inbound OIDC(
tenant_oidc_providers,每租户配企业 IdP,可停用)——但只支持标准 OIDC(依赖 discovery + id_token + 标准 userinfo),接不上 Gitee/微信等 OAuth2 平台。
目标是统一成一套配置:租户管理员在 UI 配一个 IdP(选类型 OIDC/OAuth2),填对应参数,就能接入任意登录源,并能停用。这是 IDaaS 的核心能力(Auth0/Casbin 的 social connections 都这么设计)。
1. 数据模型扩展(tenant_oidc_providers 加字段)
ALTER TABLE tenant_oidc_providers ADD COLUMN provider_type VARCHAR(10) DEFAULT 'oidc'; -- 'oidc' | 'oauth2'
-- OAuth2 手填 endpoints(OIDC 用 discovery,这几列留空)
ALTER TABLE tenant_oidc_providers ADD COLUMN authorize_url TEXT;
ALTER TABLE tenant_oidc_providers ADD COLUMN token_url TEXT;
ALTER TABLE tenant_oidc_providers ADD COLUMN userinfo_url TEXT;
-- OAuth2 userinfo 字段映射(OIDC 用标准 sub/email,这几列留空走默认)
ALTER TABLE tenant_oidc_providers ADD COLUMN field_id VARCHAR(50) DEFAULT 'id';
ALTER TABLE tenant_oidc_providers ADD COLUMN field_username VARCHAR(50) DEFAULT 'login';
ALTER TABLE tenant_oidc_providers ADD COLUMN field_email VARCHAR(50) DEFAULT 'email';
ALTER TABLE tenant_oidc_providers ADD COLUMN field_name VARCHAR(50) DEFAULT 'name';
ALTER TABLE tenant_oidc_providers ADD COLUMN field_avatar VARCHAR(50) DEFAULT 'avatar_url';
-- OAuth2 token 传递与编码
ALTER TABLE tenant_oidc_providers ADD COLUMN userinfo_token_in VARCHAR(10) DEFAULT 'query'; -- 'query'(?access_token=) | 'header'(Authorization: Bearer)
ALTER TABLE tenant_oidc_providers ADD COLUMN token_content_type VARCHAR(10) DEFAULT 'form'; -- 'form'(x-www-form-urlencoded) | 'json'
现有 IdP 配置迁移:provider_type='oidc'(默认),新字段空走 OIDC discovery,零破坏。
2. RP 逻辑泛化(src/sso.rs)
start(GET /api/auth/sso/:provider_id/start)
按 provider_type 分支:
- oidc:
discover(issuer)拿 authorization_endpoint + 生成 PKCE(code_challenge)+ state 存 Redis → 302。(现有逻辑) - oauth2:用
authorize_url(手填)+ state 存 Redis(不生成 PKCE,多数 OAuth2 如 Gitee/GitHub 不要求;若某些平台要,可后续加use_pkce开关)→ 302。
callback(GET /api/auth/sso/callback?code=&state=)
- 换 token:
- oidc:
POST discovery.token_endpoint+code_verifier(PKCE)→{access_token, id_token}。 - oauth2:
POST token_url,body 按token_content_type(form/json),含grant_type=authorization_code, code, client_id, redirect_uri, client_secret(无 code_verifier)→{access_token}。
- oidc:
- userinfo:
- oidc:
GET discovery.userinfo_endpoint+Authorization: Bearer→ 标准{sub, email, name}。 - oauth2:
GET userinfo_url,token 按userinfo_token_in(query?access_token=或 header)→ 按field_*映射取{id, username, email, name, avatar}。
- oidc:
- 用户映射(统一):
oauth_provider = format!("idp:{}", provider_id),oauth_id = mapped_id。
公开端点(GET /api/auth/sso/providers?tenant_id=)
返 [{id, name, provider_type}](加 provider_type 供登录页按类型显示图标/文案;不返敏感)。
3. 字段映射示例(覆盖主流 OAuth2 平台)
| 平台 | authorize_url | token_url | userinfo_url | field_id | field_username | userinfo_token_in | token_content_type |
|---|---|---|---|---|---|---|---|
| Gitee | https://gitee.com/oauth/authorize | https://gitee.com/oauth/token | https://gitee.com/api/v5/user | id | login | query | form |
| GitHub | https://github.com/login/oauth/authorize | https://github.com/login/oauth/access_token | https://api.github.com/user | id | login | header | json |
| 通用 OAuth2 | 各自填 | 各自填 | 各自填 | 配置 | 配置 | 配置 | 配置 |
租户管理员在 UI 填这些 → 接入对应平台。
4. UI(admin-ui/src/sso_providers.vue)
- 表单加「类型」选择:OIDC / OAuth2。
- OIDC 显示:issuer(discovery 用)。
- OAuth2 显示:authorize_url / token_url / userinfo_url + 字段映射(field_id/username/email/name/avatar)+ userinfo_token_in(query/header)+ token_content_type(form/json)+ scope。
- 通用:name / client_id / client_secret / is_active(停用开关)。
- 预置模板(可选 UX 加分):点「Gitee 模板」自动填 Gitee 的 URL/字段映射,租户只补 client_id/secret。
5. 硬编码 GitHub/Gitee 已退役(2026-07)
- 硬编码
github_oauth_*/gitee_oauth_*handler 与.env的GITHUB_*/GITEE_*配置已移除。 - 全部统一到配置化 IdP:社交登录(GitHub/Gitee)和企业 SSO(OIDC)都走
tenant_oidc_providers表 +/api/auth/sso/*路径。 - 功能平移:db.rs 启动迁移在 default 租户下自动创建 GitHub OAuth2 IdP(读
.env的GITHUB_CLIENT_ID/SECRET,幂等判重);Gitee 早已预置(id=1)。 - 历史
oauth_provider值:硬编码'github'/'gitee'(遗留账号)vs 配置'idp:{id}'(新登录)。两套不冲突,老账号原值不变。
6. 工作量(1 人估)
| 子项 | 工作量 |
|---|---|
| 表扩展 + 迁移 + 模型字段 | ~1 天 |
| RP 逻辑泛化(start/callback 按 type 分支 + 字段映射 + token 传递) | ~3 天 |
| 公开端点 + UI(sso_providers.vue type 选择 + 条件字段 + 模板) | ~2 天 |
| 测试(oidc 路径不回归 + oauth2 用 Gitee 实测) | ~1 天 |
| 合计 | ~1 周 |
7. 边界(本阶段不做)
- ❌ 硬编码 GitHub/Gitee 迁移到 IdP 配置(保留兼容,迁移影响现有用户 oauth_provider 值)。
- ❌ 微信/飞书自有协议适配(它们连标准 OAuth2 都不算,有 openid/app_id 等特有字段,要再单独适配;本阶段只支持「标准 OAuth2 + 字段映射」能覆盖的平台如 Gitee/GitHub)。
- ❌ SAML(仍等大客户要再做)。
- ❌ 租户级硬编码停用(硬编码是平台预置全局,租户不能停;租户只管自己配的 IdP)。
阶段 5-B:Inbound OIDC(企业 SSO)设计
auth 反向当一次 OIDC 客户端(RP),让企业租户的员工用企业自家的 IdP(如企业自建 OIDC、Keycloak、Authentik、Azure AD OIDC、甚至另一个 auth 实例)登录 auth。 区别于 auth 现有的 Outbound OIDC(auth 是提供方,租户的客户端用 auth 登录)。 定稿 2026-07-10。整体路线见
docs/TENANT_PHASE1.md。
0. 复用基础
- GitHub OAuth 用户模式(
handlers.rs的oauth_provider/oauth_id字段 + 首次登录自动建用户)—— Inbound OIDC 用户映射照搬,只是 provider 换成企业 IdP。 - auth 自己的 OIDC 提供方逻辑(
/authorize/token/userinfo/jwks)—— 反过来理解协议,RP 端逻辑对称。 reqwest(HTTP)+jsonwebtoken(验 IdP 签的 id_token,可选)+Redis(state)—— 手写 RP,不引openidconnectcrate。
1. 流程(Authorization Code + PKCE)
①用户点「企业 SSO 登录」(选租户 acme)
②auth → 302 重定向到企业 IdP 的 authorization_endpoint
?client_id=...&redirect_uri=https://auth.ai-as.cc/api/auth/sso/callback
&response_type=code&scope=openid profile email
&state=<随机,存Redis>&code_challenge=<PKCE S256>&code_challenge_method=S256
③用户在企业 IdP 登录 + 同意
④IdP → 302 回 https://auth.ai-as.cc/api/auth/sso/callback?code=<code>&state=<state>
⑤auth 校验 state(Redis 取出比对,防 CSRF)+ POST IdP token_endpoint 换 token
body: grant_type=authorization_code, code, redirect_uri, client_id, code_verifier=<PKCE>
返 {access_token, id_token}
⑥auth 用 access_token GET IdP userinfo_endpoint → {sub, email, name, ...}
⑦用户映射:oauth_provider = "oidc_inbound:{provider_id}", oauth_id = sub
首次 → 自动建本地用户(tenant_id=provider 配的租户, must_change_password=false)
已存在 → 直接登录
⑧auth 签自己的 JWT → 重定向前端 #/oauth-callback?token=...(复用现有前端 callback 流程)
2. 表
CREATE TABLE tenant_oidc_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id VARCHAR(50) NOT NULL, -- 该 IdP 属哪个租户(用户登录后归此租户)
name VARCHAR(100) NOT NULL, -- 显示名(如「公司 AD」)
issuer VARCHAR(255) NOT NULL, -- IdP issuer(discovery 从 {issuer}/.well-known/openid-configuration 拿 endpoints)
client_id VARCHAR(100) NOT NULL,
client_secret VARCHAR(255), -- 机密 client 的 secret(公开 client 留空)
scope VARCHAR(200) DEFAULT 'openid profile email',
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT (datetime('now','+8 hours')),
updated_at DATETIME DEFAULT (datetime('now','+8 hours'))
);
CREATE INDEX idx_oidc_providers_tenant ON tenant_oidc_providers(tenant_id);
3. IdP 配置:discovery 优先 + 手填兜底
- 默认:从
{issuer}/.well-known/openid-configuration拉authorization_endpoint/token_endpoint/userinfo_endpoint/jwks_uri,缓存(Moka,1h)。 - 兜底:表加可选字段
authorization_endpoint_override等,IdP 不支持 discovery 时手填。
4. API
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | /api/auth/sso/:provider_id/start | 公开 | 生成 state+PKCE 存 Redis,302 重定向到 IdP /authorize |
| GET | /api/auth/sso/callback?code=&state= | 公开 | 校验 state → 换 token → userinfo → 映射/建用户 → 签 auth token → 302 前端 callback |
| GET | /api/tenant/oidc-providers | 租户管理员 | 列本租户的 IdP 配置 |
| POST/PUT/DELETE | /api/tenant/oidc-providers/:id | 租户管理员 | CRUD(租户管理员只能管自己租户的) |
| GET | /api/admin/tenants/:id/oidc-providers | 平台 admin | 跨租户查看(运维) |
/api/auth/sso/* 是公开入口(未登录),跟 /api/auth/login 同级。
5. 用户映射(复用 GitHub OAuth 模式)
oauth_provider = format!("oidc_inbound:{}", provider_id)oauth_id = userinfo.sub(IdP 内唯一,最稳;不用 email 防 IdP 允许改邮箱)- 首次登录:
INSERT users (username=email 或 name, password_hash=随机不可用, oauth_provider, oauth_id, tenant_id=provider.tenant_id, must_change_password=false) - 后续:
SELECT ... WHERE oauth_provider=? AND oauth_id=?命中即登录。 - 登录后签的 token 含
tenant_id,中间件按租户隔离(阶段1 链路自动生效)。
6. 前端
- 登录页加「企业 SSO 登录」入口:
- 起步:输入租户 id(如
acme)→ 列该租户的 active IdP → 点其中一个跳/api/auth/sso/:id/start。 - 后续:租户子域名(
acme.auth.ai-as.cc)自动定租户(要 DNS/证书,晚一步)。
- 起步:输入租户 id(如
- 租户管理页加「SSO 配置」tab(或独立页):租户管理员 CRUD IdP(name/issuer/client_id/secret/scope),平台 admin 也能看。
- callback 复用现有
callback.vue(hash 带 token → applyLogin)。
7. 安全
- state:随机 + Redis 存(key
sso:state:{state}→{provider_id, code_verifier, redirect_to},TTL 10min),callback 比对 + 一次性删除(防重放)。 - PKCE:code_verifier 随机 + code_challenge=S256(随 state 存 Redis),换 token 时带 code_verifier(防 code 拦截)。
- IdP token 不出后端:access_token/id_token 仅 auth 后端用,不返前端。
- userinfo 可选验签:拿 JWKS 验 id_token 签名(防伪造),起步可先只信 HTTPS+userinfo(够用),后续加严。
8. 工作量(1 人估)
| 子项 | 工作量 |
|---|---|
| tenant_oidc_providers 表 + CRUD API + 权限 | ~1.5 天 |
| RP 逻辑(start/callback/换 token/userinfo/state/PKCE/discovery 缓存) | ~3-4 天 |
| 用户映射(复用 GitHub OAuth 模式) | ~1 天 |
| 路由 + 前端 callback 衔接 | ~0.5 天 |
| 前端(登录页 SSO 入口 + 租户 IdP 配置页) | ~2-3 天 |
| 测试(Keycloak 或另一个 auth 实例当 IdP) | ~1-2 天 |
| 合计 | ~1.5-2 周 |
难点:OIDC 协议细节(PKCE 正确性、discovery 解析、token 交换错误处理)+ 真实 IdP 集成测试。
9. 推荐决策(起步,可调)
- discovery 优先(填 issuer 自动拿 endpoints),手填兜底。
- 登录入口:起步用「输入租户 id 选 IdP」,子域名后续。
- 用户创建:首次 SSO 自动建(免管理员预建)。
- 手写 RP(复用 reqwest + jsonwebtoken,不加 openidconnect crate)。
- 起步可先不验 id_token 签名(信 HTTPS + userinfo),后续加 JWKS 严验。
10. 不在本阶段做
- ❌ SAML(AD/Okta/Azure 的 SAML 协议,最难,等大客户要再做,~2.5 周)。
- ❌ 租户子域名 + 自动定租户(要 DNS/通配证书)。
- ❌ IdP → auth 的用户/组同步(SCIM,企业大客户功能)。
- ❌ 多 IdP per 租户的复杂 UI(表支持,UI 起步单 IdP 即可)。
阶段 5-A:计费与用量设计
auth 多租户 IDaaS 商业化第一块——按租户用量计费、收钱、配额执行。 目标:能知道每个租户用了多少、能按套餐收费、超额拦截。 本文档为可追溯设计依据,定稿于 2026-07-10。整体路线见
docs/TENANT_PHASE1.md。
0. 现有可复用基础
tenants表已有max_users(配额字段雏形)。audit_logs已记录大部分事件(登录/验证/CRUD),可作用量数据源。enforce_user_quota/enforce_tenant_usable(tenant.rs)是配额校验的现成模式,可泛化。- RBAC + 中间件 + RequestMeta.tenant_id 已就绪,埋点能拿到 tenant 上下文。
1. 用量采集
原则:埋点不阻塞主链路(fire-and-forget 到内存 channel,后台批写)。
采集指标
| metric | 含义 | 采集点 |
|---|---|---|
mau | 月活用户(按 user 去重) | login / OAuth grant 成功 |
api_calls | 受保护 API 调用数 | auth_middleware(每次带 token 请求) |
tokens_issued | token 签发数 | login / refresh / OAuth grant |
oauth_logins | OAuth 授权次数 | /oauth/token 各 grant |
存储:Redis 实时计数 + 定时落库
- Redis:
HINCRBY usage:{tenant_id}:{yyyymmdd} {metric} 1(hash,每天一行)。 - MAU 去重:
SADD usage:mau:{tenant_id}:{yyyymm} {user_id}(set,月末 SCARD)。 - 定时任务(每小时):Redis →
usage_daily表(持久化 + 清 Redis 旧 key)。
usage_daily 表
CREATE TABLE usage_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id VARCHAR(50) NOT NULL,
date CHAR(8) NOT NULL, -- yyyymmdd
api_calls INTEGER DEFAULT 0,
tokens_issued INTEGER DEFAULT 0,
oauth_logins INTEGER DEFAULT 0,
mau INTEGER DEFAULT 0, -- 仅当天去重用户数(月聚合另算)
created_at DATETIME DEFAULT (datetime('now','+8 hours')),
UNIQUE(tenant_id, date)
);
CREATE INDEX idx_usage_tenant_date ON usage_daily(tenant_id, date);
2. 套餐与订阅
CREATE TABLE plans (
id VARCHAR(50) PRIMARY KEY, -- free / pro / enterprise
name VARCHAR(100) NOT NULL,
price_monthly INTEGER NOT NULL, -- 单位:分
max_users INTEGER NOT NULL, -- 0 = 不限
max_api_calls_monthly INTEGER NOT NULL,
max_oauth_clients INTEGER NOT NULL,
features TEXT DEFAULT '[]', -- JSON: ["sso","branding","audit_export",...]
is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE subscriptions (
tenant_id VARCHAR(50) PRIMARY KEY REFERENCES tenants(id),
plan_id VARCHAR(50) NOT NULL REFERENCES plans(id),
status VARCHAR(20) NOT NULL, -- trialing / active / past_due / canceled
current_period_end DATETIME, -- 当前周期结束(到期续费/降级)
stripe_customer_id VARCHAR(100), -- 支付网关客户 ID
stripe_subscription_id VARCHAR(100),
created_at DATETIME DEFAULT (datetime('now','+8 hours')),
updated_at DATETIME DEFAULT (datetime('now','+8 hours'))
);
plans 预置(db.rs seed):free(0 元,限 10 用户/1k 调用)、pro(99 元/月,1k 用户/100k 调用)、enterprise(999 元/月,不限 + SSO + 品牌)。
3. 配额执行(泛化 enforce_user_quota)
把 tenant.rs 的 enforce_user_quota 泛化:
#![allow(unused)]
fn main() {
async fn enforce_quota(db: &Database, tenant_id: &str, metric: QuotaMetric) -> Result<()>
// QuotaMetric: Users / OauthClients / ApiCalls(monthly) / Storage
}
create_user→ enforce_quota(Users)(已有,重构)。create_oauth_client→ enforce_quota(OauthClients)。- API 调用(auth_middleware)→ 月度 api_calls 计数(Redis),超额返 429(限流,不拒服务)或降级提示。
4. 计费账单 + 支付
推荐:Stripe Billing(国际,最省事)
- 创建 Stripe Customer(租户首次订阅)→ Subscription(按 plan)→ Invoice(周期账单)。
- Webhook
invoice.paid/invoice.payment_failed同步subscriptions.status(active/past_due)。 - 前端用 Stripe Checkout / Customer Portal(自助管理订阅/换卡),不用自建支付表单。
- 国内客户:Stripe 支持支付宝(需 Stripe 账户开通),或单独对接支付宝微信(自建,工作量大)。
欠费处理
past_due(支付失败):宽限期(3-7 天)→ 仍失败降级(功能只读/隐藏 SSO)→ 不删数据。canceled:数据保留 N 天(可恢复)→ 到期删除(合规)。
自建账单(备选,国内为主)
invoices表(金额/周期/状态)+ 支付宝微信支付接口 + 手动对账。工作量大,不推荐除非纯国内。
5. API
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | /api/tenant/usage | 租户管理员 | 本租户用量(当月 MAU/调用/token + 趋势) |
| GET | /api/tenant/subscription | 租户管理员 | 当前套餐/周期/账单历史 |
| POST | /api/tenant/subscribe | 租户管理员 | 选套餐 → 返 Stripe Checkout session URL |
| GET | /api/admin/tenants/:id/usage | 平台 admin | 指定租户用量 |
| GET | /api/admin/billing/overview | 平台 admin | 全平台收入/用量总览 |
| POST | /api/webhooks/stripe | 公开(签名校验) | Stripe 事件回调(订阅状态同步) |
/api/tenant/* 是租户管理员域(持 system:* 本租户权限 + 不持 admin:manage),跟 /api/admin/*(平台 admin)区分。
6. 前端
- 租户管理页加「用量」tab:MAU/调用/token 图表(echarts 或简易柱状)。
- 租户管理员(需建 role_type=custom 的租户 admin 角色):
- 「订阅与账单」页:当前套餐 + 用量进度条 + 账单历史 + 升级按钮(跳 Stripe Portal)。
- 平台 admin「计费总览」页:各租户收入/用量/到期提醒。
7. 计价模型(待你定,影响 plans 配置)
| 模型 | 适合 | 复杂度 |
|---|---|---|
| 固定套餐(free/pro/enterprise 月费) | 用户量可控、功能分层 | 低(plans 表即可) |
| 按 MAU(每活跃用户月费) | To SaaS 开发者、用量波动大 | 中(MAU 精确去重 + 按量结算) |
| 按调用(每万次 API) | API 优先、机器调用多 | 中(调用计数 + 按量) |
| 混合(套餐 + 超额按量) | 兼顾稳定 + 弹性 | 中高 |
建议起步:固定套餐(最简,3 档),跑通后再加超额按量(pro 套餐含 X 调用,超出按量)。
8. 成本(1 人估)
| 子项 | 工作量 |
|---|---|
| 用量采集(埋点 + Redis + usage_daily + 定时任务) | ~1 周 |
| plans/subscriptions 表 + 配额泛化 | ~0.5 周 |
| Stripe Billing 集成(Customer/Subscription/Webhook) | ~1.5 周 |
| API(usage/subscription/webhook) | ~0.5 周 |
| 前端(用量图表 + 订阅页 + 总览) | ~1 周 |
| 合计 | ~4-4.5 周 |
难点:Stripe Billing 状态机(trialing/active/past_due/canceled + webhook 幂等);用量采集性能(异步队列不阻塞主链路)。
9. 增量落地(每步可独立上线)
- 用量采集 + usage_daily(先看清谁用了多少,不收费)—— 1 周。先做这步,决策有数据支撑。
- plans/subscriptions + 配额泛化(能限超额,免费 + 限额)—— 0.5 周。
- Stripe Billing(真能收钱)—— 1.5 周。
- 前端用量/账单页(自助体验)—— 1 周。
10. 不在本阶段做(边界)
- ❌ SSO 企业版(阶段 5-B 另设计:Inbound OIDC / SAML)。
- ❌ 合规认证(SOC 2 / ISO 27001,流程非代码,数月 + 第三方)。
- ❌ 计价模型动态配置(起步用 plans 表硬编码三档即可)。
- ❌ 退款/税务/多币种(Stripe 默认能力,不自建)。