Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AuthKeystone IDaaS 下游接入指南

AuthKeystone 是 OIDC 身份提供方(IDaaS),下游业务服务(ExoMind/wechat/…)通过标准 OIDC 接入。 本文档是下游接入的契约与规范——照着做,身份层零踩坑。定稿 2026-07-11。


1. 四层身份模型(务必理解)

AuthKeystone 的身份架构是四层正交,下游接入前必须分清:

字段语义下游怎么用
身份(Who)sub用户唯一稳定标识(= user_id,跨改名/换组织不变)用户主键:个人知识库、配额、偏好、会话——所有「按用户隔离」的场景都用它
组织(Whose)org_id组织容器(个人/团队/企业)仅团队/企业数据隔离;个人场景不需要。绝不用 org_id 当用户身份
权限(What)permissionsRBAC 权限列表授权判断(或调 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所有
profilepreferred_username, name要展示用户名的(picture 头像字段规划中,暂不签发)
emailemail, email_verified要邮箱的
auth:permissionspermissions[]要做 RBAC 授权的
auth:orgorg_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 的 DNS
  • preconnect:进一步提前完成 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_session cookie 残留在浏览器
  • 用户再点登录 → /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(Node openid-client / Python authlib / Java pac4j)发现后会自动在 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-configurationOIDC 发现文档(所有端点 + 支持的 scope/claim/grant)
GET /.well-known/jwks.jsonRS256 验签公钥(本地验 token)
GET /authorize授权码签发(用户登录入口)
POST /oauth/token换 token(authorization_code / password / refresh_token / client_credentials)
GET /api/userinfo用户信息(Bearer token → 标准 OIDC claims)
POST /oauth/introspecttoken 内省(实时查有效性 + 权限)
POST /oauth/revoketoken 吊销
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. 关键规范(务必遵守)

  1. sub 做用户主键——这是 OIDC 标准,跨组织/改名/换租户都稳定。知识库、配额、会话、偏好,全用它。
  2. org_id 仅用于团队/企业隔离——做团队空间、企业数据隔离。个人场景(每个用户独立空间)用 sub,不要 org_id
  3. 验 token 时校验 aud = 你的 client_id——防止别的业务的 token 被你误用。
  4. 只声明你需要的 scope——最小暴露,AuthKeystone 会追踪谁用了哪些字段(迭代时评估影响)。
  5. 业务自定义 scope 走申请制——向 auth SaaS 管理员申请(如 exomind:wiki),不要擅自用未注册的 scope。
  6. 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_xxxX-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_sessionAuthKeystone 的 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 保护
内置 adminadmin 角色(种子)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 字段)。

六、已知限制与后续计划

  1. department 数据范围在 list_users 未完全实现:当前 list_users 只有 all/self 两个分支,department 降级为 self。后续需补 department 分支(WHERE dept_id = 操作者的 dept_id)。

  2. 数据范围只作用于用户管理:角色/菜单等系统配置不受 data_scope 影响。如果业务需要“角色也按部门隔离“,需扩展 enforce_data_scope 到这些模块。

  3. API Key 没有数据范围:API Key 的权限通过 permissions 数组控制,不涉及 data_scope。资源隔离通过 account_key 实现(多租户维度)。

  4. 菜单/角色无租户隔离:所有用户共享同一套菜单树和角色定义。如需“不同租户看到不同菜单“,需加 tenant_id 到 menus/roles 表。


附:权限检查代码索引

功能代码位置说明
功能权限检查middleware::check_permission单权限检查
多权限检查(OR)middleware::check_any_permissionapikeys 等多权限域
数据范围获取admin::get_user_data_scope查用户角色的最宽 data_scope
数据范围拦截admin::enforce_data_scopeupdate/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* 路由与 .envGITHUB_* / 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

  1. 打开 https://github.com/settings/developers
  2. 点击 New OAuth App
  3. 填写:
    • Application name: Auth Service(或你的应用名)
    • Homepage URL: https://www.ai-as.cc
    • Authorization callback URL: https://auth.ai-as.cc/api/auth/github/callback
  4. 点击 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 ActionsKeycloak client scopesAzure optional claims
per-client 配置每个 client 配自己的 scope/claim(Keycloak client scopes / Auth0 Actions 按 client_id / Azure app registration)scope vs roles
多租户单部署多 Organization(逻辑隔离),趋势替代每租户独立 realmAuth0 OrganizationsKeycloak 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 配置)

scopeclaim谁该要
openid(默认)sub所有下游
profilepreferred_username, name, picture要展示用户名的
emailemail, email_verified要邮箱的
auth:permissionspermissions[]要做 RBAC 授权的
auth:orgorg_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_idroles.org_idapi_keys.org_idaudit_logs.org_iddepartments.org_idoauth_clients.org_idtenant_oidc_providersorg_idp_providers(org 级 IdP)。
  • tenants 表 → organizations(org 主表)。
  • 代码:tenant_idorg_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.rs Claims: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 列 rename org_id(SQLite 不支持直接 rename column,用「新建列 + 迁数据 + 旧列留空兼容」或重建表;或若可接受,保留列名 tenant_id 但代码层全用 org_id——决策:彻底 rename,重建表迁移)。
  • tenantsorganizations 表 rename。
  • 代码全局 tenant_idorg_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.rsClaims(sub/aud/preferred_username) + 签 token
src/models.rsClaims/User/Role/… tenant_id→org_id + ClientScope/OAuthClientScope
src/middleware.rsRequestMeta tenant_id→org_id
src/db.rs表列 rename org_id + organizations + client_scopes + oauth_client_scopes + seed
src/admin.rstenant→org + scope CRUD + client scope 配置
src/handlers.rstenant→org
src/sso.rstenant→org
src/tenant.rs → src/org.rsrename + tenant→org
src/oauth.rsaud + scope 注入 claim
src/oauth_client.rstenant→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)

  1. 注册 OAuth client(client_id/secret/redirect_uri/scopes)。
  2. 声明用哪些 scope(auth 配置 client_scopes)——「我需要哪些字段」的契约。
  3. sub 做用户主键(标准,跨组织稳定)—— 绝不用 org_id 当用户身份。
  4. org_id 只用于团队/企业隔离(若需要)—— 与 sub 正交。
  5. 验 token 校验 aud(= 自己 client_id)—— 防 token 跨业务误用。
  6. 业务自定义 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_iddb.rs:61,含迁移 db.rs:688-704值 = username(每用户一租户)
JWT Claims.tenant_idauth.rs:24登录时从 user 带出
TokenInfo.tenant_idmodels.rs:119同上
登录 tenant_id = COALESCE(tenant_id, username)handlers.rs:850兜底为 username
超级权限 admin:manageSUPER_PERMISSIONmodels.rsmiddleware.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_idUPDATE 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:manageSUPER_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:688pragma_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.rslist_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.rsget_user_permissions / get_user_menu_treeuser→role→menu 链,roles 加 tenant_id 后天然隔离,SQL 无需改;但 assign_role 要校验 role.tenant_id == user.tenant_id
api_keys.rs / oauth_client.rsCRUD 加 tenant

6.4 模型层(models.rs

Role / ApiKey / OAuthClient / Department / AuditLogpub 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. 换 PostgreSQLsqlx 兼容迁移 + RLS 硬兜底 + 读写分离 + 数据搬迁横扩就绪
3. 租户管理 + 自助tenant CRUD / 配额 / 品牌 / 登录页配置;admin-ui 多租户化可对外卖
4. 横扩 + 可观测多实例 + nginx LB + Prometheus / OpenTelemetry / tracing生产级 SLO
5. 商业化计费 / 用量统计 / 合规导出 / SSO 企业版商业闭环

附:阶段 1 已确认的决策

  1. users.tenant_id 全部折叠到 "default" 单租户(不保留「每用户一租户」)
  2. ✅ 平台管理员复用现有 admin:manage 超级权限(= 跨租户),不新增概念
  3. menus / dict_* 平台共享,不加 tenant_id
  4. 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 硬编码(commit 1a058c7)。


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 分支:

  • oidcdiscover(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:
    • oidcPOST discovery.token_endpoint + code_verifier(PKCE)→ {access_token, id_token}
    • oauth2POST token_url,body 按 token_content_type(form/json),含 grant_type=authorization_code, code, client_id, redirect_uri, client_secret(无 code_verifier)→ {access_token}
  • userinfo:
    • oidcGET discovery.userinfo_endpoint + Authorization: Bearer → 标准 {sub, email, name}
    • oauth2GET userinfo_url,token 按 userinfo_token_in(query ?access_token= 或 header)→ 按 field_* 映射取 {id, username, email, name, avatar}
  • 用户映射(统一):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_urltoken_urluserinfo_urlfield_idfield_usernameuserinfo_token_intoken_content_type
Giteehttps://gitee.com/oauth/authorizehttps://gitee.com/oauth/tokenhttps://gitee.com/api/v5/useridloginqueryform
GitHubhttps://github.com/login/oauth/authorizehttps://github.com/login/oauth/access_tokenhttps://api.github.com/useridloginheaderjson
通用 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 与 .envGITHUB_* / GITEE_* 配置已移除
  • 全部统一到配置化 IdP:社交登录(GitHub/Gitee)和企业 SSO(OIDC)都走 tenant_oidc_providers 表 + /api/auth/sso/* 路径。
  • 功能平移:db.rs 启动迁移在 default 租户下自动创建 GitHub OAuth2 IdP(读 .envGITHUB_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.rsoauth_provider/oauth_id 字段 + 首次登录自动建用户)—— Inbound OIDC 用户映射照搬,只是 provider 换成企业 IdP。
  • auth 自己的 OIDC 提供方逻辑/authorize /token /userinfo /jwks)—— 反过来理解协议,RP 端逻辑对称。
  • reqwest(HTTP)+ jsonwebtoken(验 IdP 签的 id_token,可选)+ Redis(state)—— 手写 RP,不引 openidconnect crate。

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-configurationauthorization_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/证书,晚一步)。
  • 租户管理页加「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. 推荐决策(起步,可调)

  1. discovery 优先(填 issuer 自动拿 endpoints),手填兜底。
  2. 登录入口:起步用「输入租户 id 选 IdP」,子域名后续。
  3. 用户创建:首次 SSO 自动建(免管理员预建)。
  4. 手写 RP(复用 reqwest + jsonwebtoken,不加 openidconnect crate)。
  5. 起步可先不验 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_issuedtoken 签发数login / refresh / OAuth grant
oauth_loginsOAuth 授权次数/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.rsenforce_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. 增量落地(每步可独立上线)

  1. 用量采集 + usage_daily(先看清谁用了多少,不收费)—— 1 周。先做这步,决策有数据支撑。
  2. plans/subscriptions + 配额泛化(能限超额,免费 + 限额)—— 0.5 周。
  3. Stripe Billing(真能收钱)—— 1.5 周。
  4. 前端用量/账单页(自助体验)—— 1 周。

10. 不在本阶段做(边界)

  • ❌ SSO 企业版(阶段 5-B 另设计:Inbound OIDC / SAML)。
  • ❌ 合规认证(SOC 2 / ISO 27001,流程非代码,数月 + 第三方)。
  • ❌ 计价模型动态配置(起步用 plans 表硬编码三档即可)。
  • ❌ 退款/税务/多币种(Stripe 默认能力,不自建)。