AuthKeystone IDaaS 下游接入指南
AuthKeystone 是 OIDC 身份提供方(IDaaS),下游业务服务(ExoMind/wechat/…)通过标准 OIDC 接入。 本文档是下游接入的契约与规范——照着做,身份层零踩坑。定稿 2026-07-11。
1. 10 分钟快速接入
按以下顺序完成一次最小可用的 Web 接入。生产环境优先使用标准 OIDC SDK;它会通过发现文档读取端点,避免硬编码。
- 在后台创建 OAuth client,登记精确的
redirect_uri;服务端 Web 应用保存client_secret,SPA 使用 PKCE。 - 按需启用
openid profile email等 scope。需要邮箱不代表邮箱已验证,见第 7 节。 - 从
https://auth.ai-as.cc/.well-known/openid-configuration发现端点,跳转授权端点并生成、保存state和 PKCE 参数。 - 回调时先校验
state,再用授权码换取 token;回调中的error也必须处理并展示给用户。 - 用 JWKS 验签 token,并校验
iss、aud(必须等于自己的client_id)、有效期和 nonce(使用时)。 - 用
sub建立或查找下游用户,创建自己的业务 session;不要把 token 当作永久 session。
最小流程:
浏览器 -> /authorize -> AuthKeystone 登录 -> 业务 callback
业务后端 -> /oauth/token -> 验签与校验 aud -> 业务 session
2. 四层身份模型
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 仅做团队空间隔离
3. 接入流程
3.1 在 auth 注册 OAuth client
平台管理员在 AuthKeystone 后台为你的服务创建 OAuth client,得到:
client_id/client_secret(机密客户端)或仅client_id(公开客户端,前端 SPA,强制 PKCE)redirect_uri(你的回调地址)grant_types(authorization_code / refresh_token / password / client_credentials;Agent 场景加 token-exchange / device_code,见「Token Exchange 与 Agent 身份」「设备授权流」)
3.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。
email_verified=true;sub才是唯一稳定主键。
3.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
按需预热认证域名连接
用户点“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预先解析认证域名。preconnect还会建立 TCP/TLS 连接,通常能缩短首次登录跳转。
把它放在登录入口所在页面或全局 layout;低频使用的管理入口无需强制添加。preconnect 会增加一次额外连接,应结合实际登录频率和性能数据启用。
静默 SSO 探测(prompt=none)
如果业务想在不打扰用户的前提下判断“是否还登录着“(SPA 启动静默续签、隐藏 iframe 刷新会话),给 /authorize 加 prompt=none:
https://auth.ai-as.cc/authorize?
client_id=你的client_id
&redirect_uri=你的回调
&response_type=code
&scope=openid
&prompt=none
- AuthKeystone 侧 SSO cookie(
auth_session)仍有效 → 静默签发 code,回调照常换 token,用户无感 - SSO cookie 已失效 → 不弹登录页,改为回调
error=login_required,前端据此决定跳登录还是保持未登录态
注意:prompt=none 是精确匹配——缺省、空串或 consent/select_account 等其它值一律落入标准授权码流程(该弹登录页就弹),不发 prompt 的 client 行为完全不变。none 与其他值组合(如 none login)是规范违例,回调 error=invalid_request。
强制重新认证(prompt=login,「切换账号」入口)
业务侧用户已登录,但想提供切换账号能力(头像菜单里「切换账号」按钮、多账号工作台等),给 /authorize 加 prompt=login:
https://auth.ai-as.cc/authorize?
client_id=你的client_id
&redirect_uri=你的回调
&response_type=code
&scope=openid
&prompt=login
- AuthKeystone 忽略现有 SSO cookie(无论是否仍有效),一律 302 到登录页要求重新认证
- 用户以新身份登录后自动跳回
/authorize完成授权,业务回调拿到新用户的 code,覆盖本地旧会话——这是 RP 侧替换老登录态的标准方式(IdP 不会主动跨域踢掉 RP 的既有会话) - 回跳时 AuthKeystone 自动剥掉
prompt=login参数,登录完成后按标准流程签 code,不会循环要求登录
典型接入:切换账号按钮直接 location.href = 上面的 authorize URL(带 state),其余与普通登录完全一致,无需新回调逻辑。
同域第一方 SPA:用 SSO cookie 兑换本地会话(/api/auth/sso-resume)
用户在业务侧(RP)完成登录后,AuthKeystone 在浏览器种了 HttpOnly 的 auth_session SSO cookie——但跨域的第三方站读不到它,同域的第一方 SPA 也没有本地 token。部署在 AuthKeystone 同域的第一方 SPA(如 admin 后台)想在启动时免密恢复本地会话:
POST /api/auth/sso-resume # 无 body;同源 fetch 自动带 auth_session cookie
- cookie 有效(服务端 SSO 会话存在且未被撤销)→
200,返回与登录同构的完整 token 对(access_token/refresh_token/user/permissions),签的是全新 token,并轮换下发新 cookie(滑窗续期) - 无 cookie / 会话失效(已登出、被踢线、会话过期)→
401,前端静默落回登录页即可——cookie 是 HttpOnly,JS 无法预判存在与否,失败是常态不是错误
cookie 语义:auth_session 的值是 opaque 会话句柄(sso_*),对应 AuthKeystone 服务端会话(Redis),有效期与 cookie 本体一致(默认 7 天)且每次使用滑动续期——不随某个 access_token 过期而失效(历史上 cookie 直接装 access_token,24h token 到期后 cookie 变砖、SSO 全线失效,已废弃)。登出 / 踢线 / 改密会即刻删除服务端会话,cookie 立即失效。
典型接入:SPA bootstrap 无本地 token 时先调它试一次,成功即恢复会话;本地 token 校验与续期双双失败时也应再试一次兜底(cookie 可能比本地 token 新,如刚在 RP 登录过——auth-client bootstrap() 已内置两条路径)。第三方跨域站点不适用(fetch 不带别站 cookie),仍走标准授权码流程。
3.4 登出与单点登出(SLO)
先由产品明确退出语义,再选择实现:
- 本地登出:清理业务自身 session。用户再次进入业务时,若 AuthKeystone 的 SSO 会话仍有效,可能被无感登录。
- 全局登出:浏览器跳转 AuthKeystone 的
/oidc/end_session,清除 AuthKeystone SSO 会话;这会影响同一浏览器中其他接入 AuthKeystone 的应用。
需要用户明确退出整个身份会话时,使用全局登出;仅离开当前业务时,本地登出即可。
为什么会无感重新登录:首次登录后 AuthKeystone 会在浏览器种一个 HttpOnly 的 auth_session SSO cookie(服务端会话句柄,默认 7 天滑动续期)。本地登出不调 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。回跳白名单按 hint 定位 client,不带则无法校验白名单 |
post_logout_redirect_uri | 可选 | 登出后回业务应用的地址(须 http(s)://)。必须提前在 OAuth client 的「登出回跳白名单」(post_logout_redirect_uris)注册,精确匹配(含尾斜杠差异,https://youhuale.cn ≠ https://youhuale.cn/);未注册 / 不匹配时 auth 不回跳,302 落 auth 首页并记 SLO_REDIRECT_REJECTED 审计 |
- 必须
window.location浏览器跳转,不能fetch——跨域请求不带 auth.ai-as.cc 的 cookie,清不掉auth_session。 - 标准库可自动:发现文档已声明
end_session_endpoint,标准 OIDC SDK(Nodeopenid-client/ Pythonauthlib/ Javapac4j)发现后会自动在 logout 调用,通常无需手写。 - 登出后 auth 后台仍显示登录态是正常的:auth 第一方后台是独立的 localStorage token 会话(双会话体系),不随 SSO cookie 清除失效,与 SLO 无关。
3.5 验 token(JWKS 本地验签,推荐)vs introspect(实时查)
- 本地验签(推荐):用
/.well-known/jwks.json的 RS256 公钥本地验 token 签名 + 校验aud=你的 client_id +exp未过期。无状态,高性能。 - introspect(备选):
POST /oauth/introspect实时查 token 有效性 + 权限。适合需要 token 实时状态(如已吊销)的场景。
3.5.1 JWKS 多密钥与轮换(kid)
/.well-known/jwks.json 输出多把 RS256 公钥(kid 各异):激活密钥在前,其后为退休密钥。验签时按 token header 的 kid 选公钥(标准 JWT 库默认行为),不要取第一把硬验。
管理员执行密钥轮换(后台「系统设置」权限)后:新 token 立即用新 kid 签发;旧密钥在宽限期(默认 7 天)内保留在 JWKS,旧 token 持续可验,不需要把在线用户踢下线。宽限期结束后旧密钥从 JWKS 移除。下游按 RFC 7517 常规缓存 JWKS 即可(缓存期建议 ≤24h,遇未知 kid 时强制刷新一次再验)。
3.6 用标准 OIDC 库接入(推荐,省去手写流程)
上面的手写流程是原理参考。生产接入直接用标准 OIDC 库:配
authority = issuer,库自动拉发现文档拿所有端点和能力,零硬编码端点。不需要@authkeystone专属 SDK——AuthKeystone 是标准 OIDC,标准库最合适(零锁定、社区维护、未来换 IdP 不改代码)。
前端 SPA(oidc-client-ts):
import { UserManager } from 'oidc-client-ts'
const mgr = new UserManager({
authority: 'https://auth.ai-as.cc', // = issuer,自动拉发现文档
client_id: '你的client_id',
redirect_uri: 'https://你的域名/cb',
scope: 'openid profile email', // 按需加 auth:permissions / auth:org
})
mgr.signinRedirect() // 登录
await mgr.signinCallback() // 收 callback 换 token
await mgr.signinSilent() // 静默续期(自动 refresh)
mgr.signoutRedirect() // 登出(自动走 end_session_endpoint)
后端(Node openid-client / Python authlib):
// Node:Issuer.discover 自动拉发现文档
const { Issuer } = require('openid-client')
const issuer = await Issuer.discover('https://auth.ai-as.cc')
const client = new issuer.Client({ client_id, client_secret, redirect_uris: ['https://你的域名/cb'], response_types: ['code'] })
const url = client.authorizationUrl({ scope: 'openid profile email', state, code_challenge, code_challenge_method: 'S256' })
const tokenSet = await client.callback('https://你的域名/cb', cbParams, { code_verifier, state })
await client.refresh(tokenSet.refresh_token) // 刷新(重查 permissions)
client.endSessionUrl({ id_token_hint: tokenSet.id_token }) // 登出
scope 与 claim
- 标准:
openid(sub)/profile(preferred_username,name)/email(email,email_verified) - AuthKeystone 扩展:
auth:permissions(permissions[],做 RBAC)/auth:org(org_id,组织隔离)。按需,在后台「客户端 Scope 配置」勾选。
⚠️ permissions 的时效性(重要)
permissions 固化在 token 里(无状态本地验签,高性能)。使用注意:
- 改权限对 auth 自身 API 即时生效:admin 改角色菜单 → AuthKeystone 自动撤销受影响用户的 session(
invalidate_sessions_for_users反查该角色所有用户)→ 旧 token 失效。 - 下游本地验签有 TTL 窗口:你用 JWKS 本地验签(只验签名、不查 session)时,session 撤销对你不生效——旧 token 的旧 permissions 在 TTL 内仍被你判为有效。这是本地验签高性能的固有代价(所有 permissions-in-token 的 IDaaS 通用)。
- 建议:高频低敏操作用 token 里的 permissions;敏感授权调
POST /oauth/introspect或POST /api/permissions/check实时查,或用短 TTL + 频繁 refresh(refresh 时 AuthKeystone 重查 permissions 更新)。
3.7 动态客户端注册(RFC 7591/7592)
下游服务自助注册 client,无需管理员手动创建:
POST https://auth.ai-as.cc/oauth/register
Content-Type: application/json
{
"client_name": "my-mcp-cli",
"redirect_uris": ["http://127.0.0.1:8300/callback"],
"grant_types": ["authorization_code", "urn:ietf:params:oauth:grant-type:device_code"],
"token_endpoint_auth_method": "none",
"scope": "openid profile"
}
响应返回 client_id、client_secret(机密 client)与 registration_access_token。用该 token 调 GET/PUT/DELETE /oauth/register/{client_id} 管理(RFC 7592),registration access token 有效期 30 天。可注册的 scope 限定 openid profile email。
4. OIDC 端点速查
| 端点 | 用途 |
|---|---|
GET /.well-known/openid-configuration | OIDC 发现文档(所有端点 + 支持的 scope/claim/grant) |
GET /.well-known/oauth-authorization-server | RFC 8414 AS Metadata(纯 OAuth2 / MCP 接入方发现 AS,内容与 OIDC 发现一致) |
GET /.well-known/jwks.json | RS256 验签公钥(本地验 token) |
GET /authorize | 授权码签发(用户登录入口) |
POST /oauth/token | 换 token(authorization_code / password / refresh_token / client_credentials / token-exchange / device_code) |
POST /oauth/device/authorize | 设备授权签发(RFC 8628,见「设备授权流」) |
POST /oauth/register | 动态客户端注册(RFC 7591,见 3.7) |
POST /api/authorize | Agent 资源授权实时决策(见「Agent 资源授权」) |
GET /api/userinfo | 用户信息(Bearer token → 标准 OIDC claims) |
POST /oauth/introspect | token 内省(实时查有效性 + 权限) |
POST /oauth/revoke | token 吊销 |
GET /oidc/end_session | 单点登出(SLO) |
5. 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"
}
aud已签发,直接开校验即可:自 2026-07-04 起,所有经/oauth/token签发的 token 都带aud= 你的client_id——authorization_code / password / refresh_token / client_credentials 四种 grant 的access_token+id_token全覆盖,服务端无需额外配置。若你方代码里仍有「JWT 无 aud claim」之类注释,是早期接入遗留的过时认知,以实际 token payload 为准(抓一个 token decode 即可见aud)。下游开启 audience 校验三步:
- 验签 claims 结构加
aud字段(access_token 可选、id_token 必填,统一用Option<String>接收即可)。- 期望值设为你自己的
client_id。- 验签时打开
validate_aud = true。走
/oauth/token(RP 正常接入的入口)拿到的 token,aud必为你的client_id。
⚠️ 第一方用户 token(aud = issuer)——下游验签必须纳入白名单(2026-09-04 修正)
auth 的用户直登端点(网页登录、微信小程序登录、短信登录等)签发的 access_token,aud 固定为 issuer(https://auth.ai-as.cc)——这是 auth 的第一方用户会话凭证(Auth0 给自家 API 签的 token 同款形态),会被你的下游场景直接消费(典型:ExoMind 小程序端拿它直调 /ingest /query)。下游 verify_jwt 若 fail-closed 只认 aud == client_id,这类 token 全部 401(ExoMind 实锤踩过)。
正确校验(一行改动):
if aud not in (YOUR_CLIENT_ID, "https://auth.ai-as.cc"): # 白名单
return 401
安全边界不变:issuer-aud token 仍是同一 issuer 签名(JWKS 验签照做)、iss 校验照做、sub/permissions 语义与授权码流程完全一致。若需严格的 per-RS audience(如 Agent 委托链),走 token exchange(§11)换 aud=resource 的委托 token。
| token 形态 | 签发入口 | aud | 下游怎么验 |
|---|---|---|---|
| client / 授权码 / password grant token | /oauth/token | 你的 client_id | aud == client_id |
| 第一方用户会话 token(网页/小程序/短信登录) | auth 登录端点 | issuer | 白名单 {client_id, issuer} |
| 委托 token(OBO) | token exchange | resource 指示器 | 按需 |
扩展层(按 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
"is_anonymous": true // 匿名访客标记(仅匿名账号携带;正式账号不输出该字段)
}
委托层(token exchange 签发的委托 token 携带)
{
"act": { // RFC 8693 §4.1 委托链,最外层 = 当前 actor
"iss": "https://auth.ai-as.cc",
"sub": "agent_manager", // 代表用户行事的 Agent client
"agent_id": "agent_007" // Agent 平台侧 agent 主键(绑定时才有)
},
"agent_id": "agent_007", // 扁平便捷 claim(工具/网关直读)
"token_type": "delegation", // sub 仍为被代表的用户(OBO 语义)
"aud": "https://mcp.erp/api" // resource 指示器(RFC 8707),未传时继承原 aud
}
用法见 Token Exchange 与 Agent 身份。
匿名访客(is_anonymous,2026-08-15 起)
AuthKeystone 支持匿名登录(POST /api/auth/anonymous,对标 Supabase Anonymous Auth):访客不注册即拿到正常 JWT 与只读能力,is_anonymous=true 标记在 claim 里(正式账号不输出该字段)。
平台侧门禁(无需下游配合):匿名账号不可 建 API Key / 注册 OAuth client / 申请角色提升——统一 403 + 提示转正。
下游建议:对写操作自查 is_anonymous,true 时引导转正(POST /api/auth/upgrade 绑邮箱,user_id 不变、数据保留)。转正后新签 token 即无此标记。闲置 30 天未转正且无资产的匿名账号由平台自动清理。
用户角色提升申请(SaaS 自服务,2026-08-15 起)
组织内用户可对本组织的自定义角色自助发起申请(POST /api/role-promotions),由本组织 system:role:approve 持有者(org_admin / 角色审批员)或平台 admin 审批。对下游的影响:用户 permissions 可能经审批增长——token 内 permissions 最长 24h 刷新,需即时感知的场景走 introspect 或 /api/userinfo。
角色有涵盖关系(如 ExoMind管理员 ⊃ 读写 ⊃ 只读,按菜单集推导):分配/申请均拒绝冗余组合,选高阶时其涵盖的低阶自动不可选。
claim 拿不拿得到,分两层看:
- 「discovery 有」≠「你的 token 有」:
scopes_supported/claims_supported声明的是 AuthKeystone 的全局能力,某 client 实际拿得到哪些取决于后台给该 client 勾选的 scope(按「client 启用 ∩ 请求」投影,没勾的 scope 对应 claim 静默不发、不报错)。拿不到先查后台 scope 勾选。name/email_verified只在/api/userinfo返,不进 token:access_token/id_token 的 claim 集不含这俩,需要必须调 userinfo,不能只本地验 token。
refresh token 轮换与重用检测(OAuth 2.1 合规)
AuthKeystone 对 refresh token 实行轮换 + 重用检测(OAuth 2.1 §4.3.1 / MCP 规范强制项):
- 每次 refresh 换发新 refresh token,旧 refresh token 立即失效。客户端必须用最近一次
/oauth/token(或/api/auth/refresh)返回的refresh_token,丢弃旧的。 - 旧 refresh token 被再次提交 → 判定泄露 → 立即吊销整条链(该用户当前 access token 进黑名单 + 所有新旧 refresh 失效),强制重新登录。这是防盗刷的安全机制:攻击者若拿了你已用过的 refresh token 去用,会触发你这条登录链自毁,迫使重登(你会察觉异常)。
- 跨端点隔离:OAuth 端点(
/oauth/token)签发的 refresh token 只能由该端点 + 对应 client 兑换,不能拿去/api/auth/refresh(admin-ui 旧端点);反之亦然。 - 客户端实现要点:拿到新 refresh token 立即替换存储;不要并发用同一个 refresh 发两次(并发的第二个会被当作重用,触发整链吊销)。标准 OIDC SDK 默认串行 refresh,通常无需特别处理。
上线兼容:本功能上线前签发的 refresh token,在宽限期内首次兑换会平滑过渡(建立家族记录),不会一上线就把用户踢下线。
6. 关键规范
- 用
sub做用户主键——这是 OIDC 标准,跨组织/改名/换租户都稳定。知识库、配额、会话、偏好,全用它。 org_id仅用于团队/企业隔离——做团队空间、企业数据隔离。个人场景(每个用户独立空间)用sub,不要org_id。- 验 token 时校验
aud= 你的 client_id——防止别的业务的 token 被你误用。 - 只声明你需要的 scope——最小暴露,AuthKeystone 会追踪谁用了哪些字段(迭代时评估影响)。
- 业务自定义 scope 走申请制——向 auth SaaS 管理员申请(如
exomind:wiki),不要擅自用未注册的 scope。 - 按产品语义选择登出方式——本地登出清业务 session;需要退出整个 SSO 会话时,浏览器跳
/oidc/end_session(带id_token_hint)。详见 3.4。
7. 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 + 拿权限。
8. 常见错误
| 错误 | 后果 | 正确 |
|---|---|---|
用 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 实时查 |
| 未定义登出语义 | 用户以为已退出,却因 SSO session 无感登录 | 明确使用本地登出或全局登出;后者跳 end_session,见 3.4 |
9. 可观测性(接入必做,便于跨服务排查)
OIDC 登录链路横跨两个系统:下游业务(发起跳转、收 callback、换 token)↔ AuthKeystone(
/authorize、/oauth/token)。这是登录主链路的核心功能,任一环节断点都需要两边日志对照才能定位。下游接入时必须在自己的三个环节打日志——否则出问题(典型如“登录后没跳回业务“)时只能看到一半,无从定位。
为什么必做
典型故障「用户登录后没回到业务应用」可能断在:
- AuthKeystone 没发 302(
redirect_uri不在白名单 / 用户无登录态 / client 停用) - AuthKeystone 发了 302,但业务 callback 处理出错
- 业务拿到 code 但换 token 失败(code 过期 / PKCE 错 / secret 错 /
redirect_uri不一致)
只看一边无法定位。AuthKeystone 侧已在 /authorize、/oauth/token 关键节点打了日志(含授权码前缀、redirect_uri、错误原因),下游必须在自己这边打对应日志,两边对照。
下游必须记录的三个环节
① 发起 /authorize 跳转前——确认“确实发起了跳转、参数对“:
- 记
client_id/redirect_uri/scope/state(自己生成的)/prompt/ 是否带code_challenge - 级别:info
② 收到 AuthKeystone 回调(你的 redirect_uri 被调用)——判断“登录是否成功“的关键:
- 拿到
code→ info(记 code 前 8 位用于和 auth 串联,见下) state校验通过(防 CSRF)→ info;不通过 → warn 并拒绝- 回调带
error(如login_required/access_denied/invalid_request)→ warn,记error+error_description。这正是“没登录成功“的直接信号,务必记
③ 用 code 换 token(POST /oauth/token):
- 请求发出:info(
grant_type、client_id) - 成功:info(拿到 token,记
sub) - 失败:warn/error,记 HTTP 状态码 + 响应体的
error+error_description。常见:invalid_grant(code 过期/已用)、invalid_client(secret 错)、invalid_request
跨服务串联(报障定位用)
AuthKeystone 日志和下游日志要能对上。两种方式(推荐都做):
- 授权码前缀(最直接):AuthKeystone 签发 code 时记
code_prefix(前 8 位)。下游在 ② 收到 code、③ 换 token 时也记 code 前 8 位。两边按这 8 位一搜即关联,无需任何额外协议。 X-Request-ID:AuthKeystone 对每个响应回传X-Request-ID响应头。下游记下它;报障时把它给 AuthKeystone 运维,用它精确捞对应请求的全部日志。
错误对照表(出问题时怎么读)
| 现象 | 看下游哪条日志 | 看 AuthKeystone 哪条日志 | 结论 |
|---|---|---|---|
| 没跳回业务 | ① 有没有发起跳转 | 有无 /authorize 入口日志 | 下游没发起 / 请求没到 auth(DNS、办公网拦截) |
回调收到 error | ② error=? | /authorize 的 warn(redirect_uri 不在白名单 / 跳登录页) | redirect_uri 配错 / 用户无登录态 |
| 回调拿到 code 但仍没登录成功 | ③ 换 token 失败的 error | /oauth/token 的 warn(invalid_grant 等) | code 过期 / PKCE / secret / redirect_uri 不一致 |
| 两边都成功但用户仍未登录 | ③ 成功拿到 token | /oauth/token 成功 | 下游 session 签发 / 验 token 逻辑问题 |
脱敏
- ✅ 记:
client_id/redirect_uri/scope/state/sub/ code 前 8 位 /error/error_description/X-Request-ID - ❌ 切勿记完整值:
access_token/id_token/refresh_token/client_secret
10. 联调检查清单
下游接入联调时逐项确认:
- 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)
- 产品已选择登出语义:仅清当前业务 session,或走
/oidc/end_session退出整个 SSO 会话(见 3.4) - 走 SLO 时:
post_logout_redirect_uri已在 OAuth client「登出回跳白名单」注册(精确匹配,注意尾斜杠),登出后验证确实回跳业务地址 - 可观测性(必做):发起 /authorize、收 callback、换 token 三环节打日志;记录 code 前 8 位 + auth 回传的
X-Request-ID便于跨服务排查(见第 9 节)
11. Agent / MCP 接入(AuthKeystone 作为 MCP server 的授权服务器)
AuthKeystone 是 MCP-ready 的 OAuth 2.1 授权服务器。MCP 规范(2025-06-18 stable)规定 MCP server = OAuth 2.1 Resource Server,需要一个授权服务器发/验 token——AuthKeystone 直接充当这个角色,MCP server 无需自建身份层。
11.1 MCP server / 客户端怎么接入
- 发现授权服务器:MCP 客户端按 RFC 8414 拉
https://auth.ai-as.cc/.well-known/oauth-authorization-server(与 OIDC discovery 内容一致),拿 issuer / authorization_endpoint / token_endpoint / jwks_uri / grant_types_supported / code_challenge_methods_supported。 - 用户登录(人通过 MCP 客户端操作):授权码 + PKCE S256(
scope=openid auth:permissions auth:org),本地 JWKS 验签,校验aud = 你的 client_id。sub做用户主键。 - 服务身份(Agent 平台控制面调 AuthKeystone 的 introspect/revoke/管理 API):用独立 M2M client(仅
client_credentials,无 redirect_uri),token 的aud/sub= client_id、TTL 15 分钟、带jti。permissions按创建路径双口径:管理端(org/system 级)来自命名空间白名单(platform:/capability:/agent:);用户自助(user 级)来自创建者权限委托(service_permissions⊆ 创建者自身权限,见 全平台客户端 3.3 节)。 - 操作授权:
permissionsclaim(module:entity:action,如platform:run:create)作为细粒度判定的输入。授权决策(capability gateway)在 Agent 平台侧,AuthKeystone 只下发权限标识。 - token 安全:refresh token 实行轮换 + 重用检测(OAuth 2.1 §4.3.1,见第 5 节末尾「refresh token 轮换与重用检测」),旧 refresh 重用会吊销整条链——客户端拿到新 refresh 立即替换,勿并发用同一 refresh 发两次。
11.2 Agent 能力全景
| 能力 | AuthKeystone 提供 | 文档 |
|---|---|---|
| 资源/工具级授权 + 实时决策 | POST /api/authorize(关系式授权,撤销立即生效,决策审计可检索) | Agent 资源授权 |
| 身份委托链 | RFC 8693 Token Exchange / OBO(act + agent_id claims,人 → Agent → 工具全链归因) | Token Exchange 与 Agent 身份 |
| Agent 一等身份 | client 绑定 agent_id,client_credentials token 携带;停用即失效 | Token Exchange 与 Agent 身份 |
| 设备流 | RFC 8628(CLI/桌面 Agent 登录) | 设备授权流 |
| resource indicators | RFC 8707(授权/token 请求带 resource → aud 绑定) | 本文 3.3 与 11.1 |
| 动态注册 | RFC 7591/7592(/oauth/register) | 本文 3.7 |
| sub / org_id 透传 | sub = 用户主键;org_id ↔ tenant 映射由平台定义 | 本文第 2 节 |
运行时隔离(Vault/KMS、Sandbox)与 collection→文档包含关系的计算归 Agent 平台。
11.3 resource indicators(RFC 8707)
授权请求带 resource 参数(绝对 URI,可重复):
GET /authorize?...&resource=https%3A%2F%2Fmcp.erp%2Fapi
- client 配置了 resource 白名单时必须子集命中;未配置则接受任意合法绝对 URI
- 换 token 后 access token 的
aud= resource(id_token 的aud恒为 client_id) - token exchange 与设备流同样支持
resource→ aud 绑定
数据权限设计文档 (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 |
身份生命周期与账户关联规范
本文定义 AuthKeystone 的目标安全策略和迁移边界。第 2 节记录已上线的现行行为(L0); 其余标为“目标”的能力在完成实现与验收前,不得在对外契约中承诺已经生效。
1. 基本原则
- 第三方身份认证不等于邮箱已验证。
sub是身份的稳定主键;email是可变资料,email_verified才能表达邮箱是否可作为安全凭据。 - 不基于原始邮箱自动合并账户。 相同字符串的邮箱不能证明两个外部身份属于同一人;邮箱作为关联凭据须满足双侧已验证(见第 2 节)。
- 先认证,再关联。 关联外部身份必须同时证明当前账户控制权和外部账户控制权。
- 最小身份先行。 没有可信邮箱的用户可以完成身份认证,但只能进入待完善或受限状态,不能直接获得一般业务权限。
2. 现行实现(L0,2026-08 已上线;多绑模型 2026-09 上线)
身份绑定存储(2026-09 起):user_idp_bindings 关联表是真源——一个账号可绑定多个外部 IdP(如 GitHub 与 Gitee 同时绑定),每条绑定 (provider_id, subject) 全局唯一。users.oauth_provider / oauth_id 两列降级为「最新一条绑定」的双轨投影(写时同事务同步,读点零改动,后续一次性退役)。绑定/登录/合并/解绑全部走表:登录按 (provider_id, subject) 直查表定位账号;解绑按 provider 选择性解一条(POST /api/auth/sso/unbind,body 带 provider_id),解绑后剩余绑定数 + 有无密码 ≥ 1 才放行;绑定列表 GET /api/auth/sso/bindings。历史硬编码 github / gitee 存量已回填(能映射到 provider 记录的映射,映射不到落 legacy 哨兵)。管理端用户更新 API 不再接受 oauth 字段直写。
邮箱可信度来源(决定 email_verified):
- OIDC IdP 的
email_verifiedclaim(缺失视为未验证) - GitHub / Gitee emails 端点条目(
verified=true/state=confirmed,primary 优先,未验证条目一律不取) - OAuth2 托管平台(GitHub / Gitee)的注册邮箱,视为平台已确认
邮箱补全:userinfo 未返回 email(如 GitHub / Gitee 私有邮箱设置)时,回退拉取 emails 端点取已验证邮箱;拉取失败降级为无邮箱登录,不阻断。老用户在 IdP 侧后补公开邮箱并重新登录后,本地账号自动补全 email 并同步置 email_verified。
JIT 建号:首次外部身份登录按 IdP 配置即时建号,email_verified 按上述来源写入;无可信邮箱时建无邮箱账号(可后续绑定邮箱补全)。
账号合并(email 关联):
- 双侧已验证才自动合并:IdP 侧邮箱已验证,且本地同邮箱账号
email_verified=TRUE,才把外部身份绑入既有账号;任一侧未验证,该邮箱不具备「同一人」的证明力,不合并 - 特权账号拒绝静默合并:同邮箱命中
admin/org_admin一律拒绝自动并入,须原账号登录后在个人中心显式绑定 - 已绑定身份再次登录按
(provider, subject)直查定位同一账号,不经邮箱合并
显式绑定 / 解绑(个人中心):登录态下主动绑定外部 IdP(可多个共存,各自独立解绑);解绑前校验账号仍保留至少一种可登录方式(剩余绑定 + 密码 ≥ 1)。
删除账号:管理员删除用户即失效其全部会话(JWT 逐 jti 清除 + SSO cookie 会话一并踢出),该用户浏览器下一次任意请求 401 回登录页。
3. 注册来源与状态
| 来源 | 身份凭据 | 邮箱状态 | 可进入的状态 |
|---|---|---|---|
| 密码注册 | 本地密码 | 验证码成功后为 verified | 正式账户 |
| OIDC / 社交登录 | (provider_id, subject) | 独立记录为 none、unverified 或 verified | JIT 策略允许时创建或登录 |
| OIDC / 社交登录,无邮箱 | (provider_id, subject) | none | 待完善、受限会话 |
| 匿名登录 | 匿名会话 | none | 受限主体;升级认证后才可成为正式账户 |
email scope 只表示用户资料可能包含邮箱,不表示邮箱可信。下游只有在收到 email_verified=true 时,才可把邮箱用于恢复、权限、通知对象确认或账户关联。
4. JIT 注册策略
首次通过外部身份登录时,按 IdP 配置决定是否允许即时创建账户:
| 策略 | 含义 | 适用场景 |
|---|---|---|
disabled | 不创建账户,只允许已关联身份登录 | 高敏感或邀请制产品 |
verified_email | 仅上游明确提供可信邮箱验证证据时创建 | 常规消费者产品 |
allowed_domain | 仅受信任域名且邮箱已验证时创建 | 企业 / 校园场景 |
provider_membership | 仅指定组织或成员关系通过时创建 | 企业 IdP、SAML/OIDC 联邦 |
Gitee、GitHub 等 OAuth 2.0 提供方返回 email 字段时,不能仅凭该字段把邮箱标记为已验证(托管平台注册邮箱除外,见第 2 节可信度来源);没有可验证的上游证据时,应要求用户完成本地邮箱验证。
5. 账户关联
现行行为与目标
不得仅凭两个身份资料中的 email 文本相同,就自动归并到同一用户。这会造成预占账户(pre-account takeover)风险:攻击者可先用未验证邮箱创建身份,等待真实邮箱持有人使用另一 IdP 登录后被错误合并。
现行实现(L0)据此要求双侧已验证才允许自动关联,且对特权账号一律拒绝静默并入(见第 2 节);目标态进一步以显式绑定为主路径,邮箱自动关联仅保留为双侧已验证场景下的便捷合并。
推荐流程
- 用户先登录现有 AuthKeystone 账户。
- 对当前账户执行近期重新认证,避免被遗留会话劫持后静默绑定。
- 用户授权外部 IdP,并验证回调中的稳定
subject。 - 检查该
(provider_id, subject)尚未属于其他用户后,写入绑定。 - 向用户展示绑定成功、解绑入口和必要的安全通知。
一个用户可拥有多个身份。目标数据模型为:
user_identities(
user_id,
provider_id,
subject,
profile_email,
email_verified,
linked_at,
last_used_at,
...
)
其中唯一约束应为 (provider_id, subject);profile_email 不应作为账户归并键。
6. 邮箱恢复与升级
- 魔法链接、密码重置和以邮箱证明账户控制权的流程,仅可发送到
email_verified=true的地址。 - 外部身份资料中的未验证邮箱,可用于展示或引导补全,不能作为找回密码或强制合并依据。
- 无可信邮箱的用户需要绑定已验证邮箱、添加密码或配置其他恢复因子后,才具备完整恢复能力。
- 解绑前应校验账户仍保留至少一个可登录、可恢复的方式;高风险操作要求近期重新认证。
7. 迁移与验收
现有只保存单个 oauth_provider/oauth_id 的账户模型,迁移到多身份模型时应:
- 回填现有第三方身份为一条
user_identities记录,保留原用户主键。 - 对历史第三方邮箱默认标记为
unverified,除非存有可审计的可信验证证据。 - 邮箱自动关联收敛为双侧已验证门禁(现行 L0 已生效);目标态移除登录路径上的静默合并,冲突时引导用户登录现有账户后手动绑定。
- 对绑定、解绑、JIT 创建、恢复分别记录审计日志。
发布前至少覆盖以下场景:
| 场景 | 预期结果 |
|---|---|
| 新用户使用带可信已验证邮箱的 IdP 登录 | 按 JIT 策略创建账户,邮箱为 verified |
| 新用户使用无可信邮箱证据的 IdP 登录 | 创建受限账户或要求本地验证,邮箱不标记为 verified |
| 已存在密码账户,同邮箱外部登录(双侧已验证) | 自动合并:外部身份绑入既有账号 |
| 同邮箱命中特权账号(admin / org_admin) | 拒绝静默合并;引导登录后在个人中心显式绑定 |
| 同邮箱但任一侧未验证 | 不合并;按 JIT 策略新建账号 |
| 已绑定身份再次登录 | 通过 (provider_id, subject) 定位同一用户 |
| 未验证邮箱请求魔法链接或重置 | 拒绝发送 |
| 解绑唯一登录方式 | 拒绝或先要求添加其他恢复方式 |
| 管理员删除用户 | 全端会话立即失效(JWT + SSO cookie),下次请求 401 |
全平台客户端
AuthKeystone 的 OAuth client 体系:三级可见性(visibility)、门户展示开关(portal_visible)、 自助接入、越级提升申请工作流,以及管理端操作。
1. 三级 visibility(可见性)
每个 OAuth client 有一个 visibility 字段,决定谁能看到、谁能写。三级从低到高:
| 级别 | 数值 | 谁能看到(list) | 谁能写(can_write_client) | 典型场景 |
|---|---|---|---|---|
user | 0 | 仅 owner(owner_user_id) | 仅 owner | 普通用户自助创建的私有 client |
org | 1 | 同组织成员(org_id 匹配) | 该组织 org_admin(system:oauth:client:edit + org 匹配) | 团队/企业共享的 client |
system | 2 | 全平台所有人 | 平台 admin | 官方 / 全局 client |
可见性查询(list_visible_clients)的过滤条件:
visibility = 'system'
OR (visibility = 'org' AND org_id = ?)
OR (visibility = 'user' AND owner_user_id = ?)
应用门户(GET /api/user/apps)与 API Key 的应用选择器共用这一数据源,再由调用方按 portal_visible 决定是否展示。
写权限按级绑定(越级不可直接改)
visibility 同时绑定写权限:只能由当前级的 authority 改写。越级提升(user→org→system)不能直接改字段,必须走提升申请工作流(见第 4 节)——防止 owner 私自把 user 级 client 提到 system 级越权。
2. portal_visible(门户展示开关)
portal_visible 是独立于 visibility 的布尔字段,只控制是否在「我的应用」门户展示,不影响权限:
true→ 出现在GET /api/user/apps列表,普通用户可见可点false→ 不出现在门户(即使 visibility 允许看到,也不展示)
典型用法:内部工具、后台 client 关掉;面向终端用户的应用打开。管理端可在 OAuth client 列表内联切换。
3. 自助接入(普通用户自己建 client)
普通用户无需 admin 权限即可注册自己的 OAuth client(默认 visibility=user)。
支持两种类型:Web 应用(用户登录接入)与 M2M 服务身份(服务间调用)。
3.1 用户域端点(/api/my/clients,按 owner 过滤)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/my/clients | 列出我(owner)的 client |
| POST | /api/my/clients | 创建我的 client(固定 visibility=user) |
| DELETE | /api/my/clients/:client_id | 删除我的 client(校验 owner 归属) |
创建响应里 client_secret 明文仅返回一次。匿名访客须先转正(POST /api/auth/upgrade)才能创建。
存量配额(2026-09 起):每用户名下 client 总数受上限约束(管理台「系统设置」的
self_service_client_quota,默认 10,热生效;0 = 关闭自助创建)。GET 列表响应带
{quota, used, remaining} 余量;超限创建返回 400(删除 client 即释放名额)。admin 通道
创建的 client 不绑 owner,不受此限。
通用约束(防越权):
visibility固定为user,不能自助指定 org/system- grant 白名单:
authorization_code/refresh_token/client_credentials redirect_uri必须http(s)://- 存量配额:见上,资源卫生约束(权限越权已由委托模型/白名单独立挡住)
3.2 两种客户端类型(grant_types 决定)
| Web 应用 | M2M 服务身份 | |
|---|---|---|
| grant_types | authorization_code(+ refresh_token,缺省即此组合) | 仅 client_credentials |
| 典型场景 | Web / SPA 应用接入登录 | 服务间调用、定时任务、机器人 |
| redirect_uris | 必填(授权码回调) | 免填(无浏览器回调) |
| scope | 只能从 openid / profile / email 选——不能塞系统 scope(如 auth:permissions、auth:org),防自助 client 越权拿权限/组织字段 | 自动置空(M2M 无用户上下文,scope 无意义) |
| 权限通道 | scope 经用户授权(consent) | service_permissions 委托(见 3.3) |
| is_public | 允许(前端 SPA,强制 PKCE) | 不允许——M2M 须机密客户端 |
| is_first_party | 默认 true(第一方免 consent) | 强制 false(无浏览器/consent 上下文) |
混合 grant(authorization_code + client_credentials)按 Web 型处理,不能携带 service_permissions——需要服务身份时单独创建一个纯 M2M client。
3.3 M2M 委托权限模型(service_permissions)
user 级 M2M 的 token 权限不是平台分配,而是创建者对自身权限的委托(与 GitHub OAuth Apps「人人可建、权限不超出本人」同款分层):
service_permissions必须是创建者自身权限(claims.permissions)的子集——只能委托自己有的,超出范围创建报 400- 换 token:
POST /oauth/token(grant_type=client_credentials)→ 机器 token 的sub/aud= client_id、token_type=client、permissions= 委托的权限集、TTL 15 分钟、带jti - 委托校验在创建时执行,配置存于 client;自助面暂无编辑端点,调整委托 = 删除重建
- 非 user 级(org/system,管理端创建)的 M2M 走命名空间白名单(
platform:/capability:/agent:),与 user 级委托是两条权限界
3.4 标准 DCR(RFC 7591/7592,机器自助接入)
下游服务也可走标准动态客户端注册,无需人工:
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /oauth/register | 注册 client,返回 registration_access_token |
| GET/PUT/DELETE | /oauth/register/:client_id | 用 registration_access_token 管自己注册的 client |
4. visibility 提升申请工作流
owner(或当前级写权者)想让 client 从 user 升到 org/system,不能直接改,要走申请→审批:
申请方(当前级写权者:owner / org_admin)
│ POST /api/oauth-clients/:client_id/promote { to_visibility: "org"|"system", note? }
▼
待审批(pending)
│ 目标级 authority 审批:
│ to=system → 平台 admin
│ to=org → 该组织 org_admin(system:oauth:client:edit + org 匹配)
▼
POST /api/promotion-requests/:id/decide { approve: bool, note? }
│
├─ approve → client.visibility 改为目标级(to=org 时归入申请指定组织)
│ ⚠ 写权随之转交目标级 authority,原 owner 失去直接编辑权
└─ reject → 状态记为 rejected,visibility 不变
端点
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /api/oauth-clients/:client_id/promote | 当前级写权者 | 发起提升申请(to 须高于 from,且只能 org/system) |
| GET | /api/promotion-requests | - | 申请列表:平台 admin 看全部;org_admin 看 to=org 本组织;其余看自己提的 |
| POST | /api/promotion-requests/:id/decide | 目标级 authority | 审批(approve/reject) |
关键规则
to_visibility必须高于from_visibility(user < org < system),且只能是org或systemto=org时,目标组织 = 申请人所在组织- approve 后写权转交:原 owner 不再能直接编辑该 client——这是有意设计,避免提升后权限与管理权错配
5. 管理端(平台 admin / org_admin)
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | /api/admin/oauth/clients | system:oauth:client:list | client 列表 |
| POST | /api/admin/oauth/clients | system:oauth:client:create | 创建(可直接指定 visibility) |
| PUT | /api/admin/oauth/clients/:client_id | system:oauth:client:edit | 更新(含 visibility / portal_visible) |
| DELETE | /api/admin/oauth/clients/:client_id | system:oauth:client:delete | 删除 |
| POST | /api/admin/oauth/clients/:client_id/rotate | system:oauth:client:edit | 轮换 client_secret |
| GET/POST | /api/admin/oauth/clients/:client_id/scopes | system:oauth:client:* | 查看 / 配置 client 的 scope |
| GET/POST/PUT/DELETE | /api/admin/client-scopes | system:oauth:client:* | scope 全生命周期 + 用量追踪 |
平台 admin 可设任意 visibility;org_admin 仅能管本组织
visibility=org的 client(Phase 2 写权限按级绑定)。
应用门户(「我的应用」)
给普通用户提供的一个轻量入口:登录后看到自己有权访问的应用,点「打开」即跳过去, 靠 OIDC SSO 免密登录。填补了普通用户(无管理权限)登录后无功能可用的空洞。
1. 端点
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/user/apps | 我的应用列表(按 visibility 三级过滤 + portal_visible) |
| GET | /api/user/permissions | 我的权限视图(角色 + 功能分组,profile「我的权限」卡数据源) |
无需管理权限,任何登录用户可调。返回结构:
{
"success": true,
"data": [
{
"client_id": "app_xxx",
"client_name": "ExoMind",
"launch_url": "https://exomind.example.com",
"authorize_url": "/authorize?client_id=app_xxx&redirect_uri=...&response_type=code&scope=openid+profile+email&state=...",
"description": "个人知识库:把散落的资料变成可检索的知识",
"capabilities": ["检索沉淀的知识", "写作与整理知识"],
"portal_visible": true,
"visibility": "system"
}
]
}
用户视角文案(description / capabilities)
门户卡片对用户回答两个问题——“这是什么“和“你能干什么”:
description:应用描述(一句话卖点),在「OAuth 客户端」编辑表单维护,所有用户相同。capabilities:当前用户在该应用里能做的动作(按人渲染——只读用户与管理员看到不同的能力行,不说谎)。数据链路:- 菜单管理里把目录(M)的「所属应用」挂到某个 client(
menus.client_id,整棵子树归属该应用); - F 按钮的「业务描述」(
menus.action_label)即用户文案,如「检索沉淀的知识」;留空回退菜单名称; - 本端点取「用户绑定的权限 ∩ 该应用子树」的文案列表,按菜单 sort_order 保序。
- 菜单管理里把目录(M)的「所属应用」挂到某个 client(
菜单名(menu_name)保持无歧义的管理锚点(如「读取权限」),角色树/菜单管理用;action_label 面向终端用户。权限标识(read/write)不进用户界面。
/api/user/permissions 返回 { roles, groups }:角色徽章(role_name/role_type)+
按功能菜单分组的操作(action_label 优先回退 menu_name,原始权限标识在
action.permission 供前端 hover 排障)。平台 admin 全量分组,普通用户按角色绑定过滤。
2. 应用可见性
门户只展示同时满足两个条件的 client:
- visibility 允许当前用户看到(
system全员 /org同组织 /user仅 owner) portal_visible=true(管理端 / OAuth client 列表可切换)
visibility 决定“能不能看到“,portal_visible 决定“要不要在门户露面“。两者正交。详见 全平台客户端。
3. 「打开」怎么免密跳转(SSO 握手)
每个应用返回两个 URL:
launch_url:应用主页(从client_uri或redirect_uri的 origin 推导)——直接跳,靠浏览器已有的 SSO cookie 免密authorize_url:走 OIDC/authorizecode 流程——若 SSO cookie 失效会触发登录,登录后回应用 callback
前端默认走 launch_url(应用首页 + SSO cookie 免密,最快);cookie 失效时落到 authorize_url 完成 OIDC 握手后跳回。
为什么需要 SSO cookie
首次登录 AuthKeystone 会在浏览器种 HttpOnly 的 auth_session SSO cookie(默认 24h)。门户“打开“应用时,浏览器带着这个 cookie 访问应用 / /authorize,AuthKeystone 认定已登录 → 免密发 code 或直接放行。cookie 过期才需重新登录。
这与下游接入的 SSO 会话是同一套 cookie。业务若选择本地登出,用户再次进入时可在 24h 内无感登录;需要退出整个身份会话时,使用全局登出。详见 下游接入指南 3.4 节。
邀请制入驻 + 组织管理员
组织(多租户)场景下,新用户不能自由注册到任意组织,须由 org_admin / 平台 admin 发邀请链接,自助注册时消费 → 落到指定组织(+ 可选角色)。配套有 org_admin 角色层与提权封堵。
1. 邀请链接
端点
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /api/admin/invites | system:user:create | 生成一次性邀请链接 |
system:user:create由 org_admin 和平台 admin 持有。
请求体:
{
"org_id": "acme",
"role_id": 5,
"ttl_hours": 168
}
org_id:目标组织。org_admin 只能填自己所在 org(强制);平台 admin 可指定任意 orgrole_id:可选。不填则注册后由 register 兜底分配该 org 的默认 user 角色;填了会校验该角色必须属于目标 org 且启用(防跨组织借邀请塞角色)ttl_hours:可选,默认 7 天
响应:
{
"success": true,
"data": {
"token": "<43 字符 base64url>",
"url": "/admin/#/register?invite=<token>",
"org_id": "acme"
}
}
前端把 url 拼成绝对地址发给被邀请人。
token 机制
- 256-bit 熵随机 token,base64url(URL 安全,无
+/=) - 存 Redis,单次使用(
GETDEL原子消费,防重放),与邮箱验证 token 一致 - TTL 到期自动失效
注册消费
被邀请人打开 register?invite=<token>,注册时带上 token。后端 consume_invite 原子取出 → 用户落到 org_id、绑 role_id(或兜底默认角色)→ token 失效。
2. 组织管理员(org_admin)角色层
每个组织有一组供 org_admin 使用的角色模板(持 system:user:create 等本组织管理权限)。org_admin 在本组织内可:
- 邀请新成员、分配角色
- 管理
visibility=org的 OAuth client - 审批
to=org的 client 提升申请
平台 admin(持 admin:manage / system:org:*)跨组织,可管任意 org。
3. 提权封堵(跨组织防护)
org::resolve_create_org 强制:组织用户创建用户 / client / 邀请时,目标 org 必须是自己所在 org,不能用 body 传别人的 org_id 越权。平台 admin 不受此限。
配套的多租户唯一性约束(早期全局 UNIQUE 在多租户下的断点修复):
users.username:全局 UNIQUE →(org_id, username)(不同组织可同名用户)roles.role_name:全局 UNIQUE →(org_id, role_name)(不同组织可同名角色)
收敛到 org 维度后,跨组织同名撞 UNIQUE 报错的断点消除。
Agent 资源授权(实时决策)
面向 Agent 平台(agent-manager / worker / model-gateway)的资源级授权:关系式授权模型 + 实时决策端点 + 决策审计 + 主体配额。
模型
资源标识
agent:<id> # Agent 实例
skill:<id> # 技能
mcp:<server> # MCP server 整体
mcp:<server>:<tool> # MCP 单个工具
collection:<id> # 知识集合
关系
授权 = 主体与资源的关系,层级从高到低:owner ⊃ editor ⊃ viewer ⊃ caller。
| 动作 | 所需最低关系 |
|---|---|
call / invoke | caller |
read / get / list / view | viewer |
write / create / update / delete / edit / publish / execute / run | editor |
manage / admin / grant / share | owner |
未知动作一律 deny(fail-closed)。
主体四级
| 主体类型 | subject_id | 说明 |
|---|---|---|
agent | 平台 agent 标识(如 agent_007) | 数字员工工号直授,独立成立——不依赖委托人授权,无授权的 Agent 不是可执行任务的数字员工 |
user | user_id(= sub) | 个人直授 |
role | role_id | 角色内全体成员 |
org | org_id | 组织内全体成员 |
判定顺序:平台 admin(admin:manage)直接放行 → agent → user → role → org,命中即停。
agent 是最具体的主体(精确到实例),命中后 reason=agent_grant:<relation>;撤销其授权后立即回退到委托人链路或 deny。
嵌套派生
mcp:<server> 级授权自动覆盖该 server 的全部工具(mcp:erp-read 授权 → mcp:erp-read:query_erp 放行)。collection → 文档的包含关系由平台侧计算后落授权记录。
决策端点
每次工具调用 / 装配判定实时请求,无 TTL 窗口,撤销立即生效。subject 必填(委托人 user,= OIDC sub)——Agent 代用户干活时的 org 隔离与委托人链路都锚定在它;context.agent_id 可选,填了才会参与 agent 直授判定与 agent 维度配额:
POST https://auth.ai-as.cc/api/authorize
Authorization: Bearer <sk_* API Key 或 client_credentials token>
Content-Type: application/json
{
"subject": "<sub>",
"resource": "mcp:erp-read:query_erp",
"action": "call",
"context": { "agent_id": "agent_007", "run_id": "r-123", "trace_id": "..." }
}
响应:
{
"decision": "allow",
"reason": "user_grant",
"trace_id": "..."
}
| 字段 | 说明 |
|---|---|
decision | allow / deny |
reason | platform_admin / agent_grant:<relation> / user_grant:<relation> / role_grant:<relation> / org_grant:<relation>(命中主体与关系,如 agent_grant:caller)/ no_grant / insufficient_relation / unknown_action / quota_exceeded / resource_not_found(资源未注册,fail-closed)/ org_mismatch(跨 org 资源) |
trace_id | 取 context.trace_id 透传(与平台 X-Trace-Id 贯通),缺省自动生成 |
remaining_quota / quota_limit | 配置了主体配额时返回 |
调用方权限:API Key 或 M2M client 的 permissions 须包含 agent:authz:decide。
每次决策(含 deny)落决策审计表,可按 subject / agent_id / resource / decision / trace_id 检索(后台「Agent 授权 → 决策审计」,或 GET /api/admin/authz/decisions?subject=&agent_id=&resource=)。
标准契约(AuthZEN 1.0 · 业界对齐)
PEP-PDP 通信对齐 OpenID Authorization API 1.0(Keycloak / Janssen 同款端点),决策核心与
POST /api/authorize 完全同源;平台接入推荐用标准契约,未来更换 PDP 实现无需改调用方。
单点评估 POST /access/v1/evaluation(与 /api/authorize 全等:注册检查 + org 隔离 +
关系判定 + 配额消费 + 决策审计;decision 为布尔,扩展信息入 context):
{
"subject": { "type": "user", "id": "42" },
"resource": { "type": "mcp", "id": "erp-read:query_erp" },
"action": { "name": "call" },
"context": { "agent_id": "agent_007", "trace_id": "..." }
}
{
"decision": true,
"context": { "reason": "user_grant:caller", "trace_id": "...", "remaining_quota": 9988, "quota_limit": 10000 }
}
批量评估 POST /access/v1/evaluations(单批 ≤ 50):装配预检语义——不消费配额、不落
决策审计;顶层 subject / resource / action 可被 evaluations 项逐项覆盖(context 项级优先),
单项格式错只影响该项(decision=false + invalid_request:*)。响应
{ "evaluations": [ { "decision": true, "context": { "reason": "..." } }, ... ] } 与请求一一对应。
List-accessible(对齐 OpenFGA ListObjects)
GET /api/authz/accessible?subject=<user_id>&resource_type=<type>&action=<action>&agent_id=<可选>
反向查询「这个主体能对哪些资源执行该动作」:平台装配 Agent 时一次拿全可用 MCP / Skill /
知识库清单,无需逐资源 check(N 次 RTT)。主体链与决策一致(agent 直授 > user > role > org),
未注册 / 跨 org 的资源不出现;纯查询不消费配额、不落审计。响应项含
resource / display_name / relation / via(如 via=agent_grant 标记来自 agent 直授)。
管理接口
后台「Agent 授权 → 资源授权」页面操作,或服务间用持 agent:grant:manage 权限的 API Key 调用:
| 端点 | 用途 |
|---|---|
POST /api/admin/authz/resources | 注册/更新资源(type + key + display_name + resource_uri) |
GET /api/admin/authz/resources | 资源列表 |
DELETE /api/admin/authz/resources/:id | 删除资源注册 |
POST /api/admin/authz/grants | 新增/续期授权关系 |
GET /api/admin/authz/grants | 授权列表(subject_type / resource_type / relation 过滤) |
DELETE /api/admin/authz/grants/:id | 撤销授权(下一次决策立即 deny) |
授权关系字段:
{
"subject_type": "agent",
"subject_id": "agent_007",
"resource_type": "mcp",
"resource_key": "erp-read:query_erp",
"relation": "caller",
"expires_at": null
}
subject_type 支持 agent | user | role | org;agent 的 subject_id 是平台侧 Agent 标识,不要求是 user_id。
expires_at 可选(北京时间 YYYY-MM-DD HH:MM:SS),到期自动失效。
org 资源隔离(方案 A)
决策路径强校验资源归属,与授权关系独立:
- 资源须先注册(
POST /api/admin/authz/resources),未注册资源一律 deny(resource_not_found)——注册是授权的前置,与平台 Resource 注册中心闭环; - 注册时填
org_id即资源归属;org_id缺省视为全局资源(所有组织可用,兼容平台级公共资源); - 委托人 org 与资源 org 不匹配即 deny(
org_mismatch),即使持有该资源的 owner 授权也拒——org 墙优先于 grant; - 平台 admin 跨组织直通不受影响。
授权域角色开放(三角色模板 + 归属护栏)
「谁能进授权域、进来能管到哪」分两层控制:角色答功能(拿到哪些权限码),归属护栏答范围(权限码能作用到哪些资源)。三角色由 seed 幂等注入(INSERT OR IGNORE,发版即生效):
| 角色 | data_scope | 权限码 | 定位 |
|---|---|---|---|
| Agent 管理员 | all | agent:resource:manage / agent:grant:manage / agent:grant:list / agent:decision:list | 组织内 Agent 体系负责人:本组织全部资源/授权/配额管理 + 全量决策审计 |
| Agent 开发者 | self | 同管理员四码 | 数字员工主理人:本人名下资源注册/授权/审计(范围由归属护栏收窄到本人) |
| Agent 观察者 | all | agent:grant:list / agent:decision:list | 只读:本组织授权清单与决策审计(合规/排障) |
agent:authz:decide 是 PDP 服务身份权限,只进平台侧决策 Key,不进任何人的角色。
归属护栏四条(非平台 admin)
- 资源注册:强制
org_id= 本人组织、owner= 本人——不能替别人注册资源; - 授权管理:授权对象限本人/本组织,且资源须已注册——未注册资源不给授权(与方案 A 的 fail-closed 同口径);
- 清单与审计:授权清单、决策审计按角色 data_scope 过滤(all=本组织、self=本人);manage/list 分类判定,防「只有 list 权限却借 manage 入口」的组合越权;
- 资源删除与配额:删除限 owner 或组织管理员;配额管理同护栏规则。
平台 admin(admin:manage)不受护栏约束(跨组织直通)。
注册默认链
「Agent 开发者」已并入新用户默认角色链:注册 / 匿名转正 / SSO 登录 / 管理台建号四条路径自动获得该角色——新用户开箱即具备自助 Agent 授权能力,无需管理员手工派权。默认角色其余部分仍以管理台「系统设置 → 注册默认角色」配置优先(未配置回退 user 角色)。
主体配额
双维度工具调用配额,决策端点实时计数(allow 才计数,deny 不消耗),任一维度超限 decision=deny, reason=quota_exceeded:
- user 维度:
subject_type=user,subject_id=user_id——按委托人限 - agent 维度:
subject_type=agent,subject_id=agent_id(取决策请求context.agent_id)——按 Agent 限
POST /api/admin/authz/quotas
{
"subject_type": "agent",
"subject_id": "agent_007",
"resource_type": "mcp",
"limit_count": 10000,
"window_secs": 86400
}
resource_type 支持 *(全部类型);无配额记录的维度不限量;平台 admin(admin:manage)的决策不计配额。
平台侧对接清单
- 后台注册 MCP server 资源(
mcp:<server>,填resource_uri供 RFC 8707 映射)。 - 建 API Key(permissions 含
agent:authz:decide),平台 PDP 用它调POST /api/authorize。决策类 Key 的默认限流自动放大为 600000/分(普通 Key 仍为 1000/分),显式传 rate_limit 时以传参为准。 - 平台
user_resource_grants/mcp_grants同步为 IdP 授权记录(POST /api/admin/authz/grants,API Key 持agent:grant:manage)。 - worker 工具调用前把
context.trace_id设为平台 X-Trace-Id,实现全链审计贯通。
Token Exchange 与 Agent 身份(RFC 8693)
Agent 代表用户调用工具时的身份委托:持用户 token + Agent 服务身份,换取带委托链的短命 token。
Agent 一等身份
在后台「OAuth 客户端」编辑页给 M2M client 填写 Agent 身份(agent_id) 后,该 client 的 client_credentials token 携带 agent_id claim:
{
"sub": "agent_manager",
"aud": "agent_manager",
"agent_id": "agent_007",
"token_type": "client",
"permissions": ["platform:run:create"]
}
client 停用(下线)后:签发立即停止,introspect 返回 active: false,已发 token 最长 15 分钟自然过期。
换取委托 token(OBO)
POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&client_id=agent_manager
&client_secret=***
&subject_token=<用户的 access_token>
&resource=https://mcp.erp/api # 可选,RFC 8707 指示器 → 新 token 的 aud
&scope=platform:run:create # 可选,与用户权限取交集
响应:
{
"access_token": "eyJ...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 900
}
委托 token 的 claims:
{
"sub": "42",
"preferred_username": "alice",
"client_id": "agent_manager",
"aud": "https://mcp.erp/api",
"token_type": "delegation",
"act": { "iss": "https://auth.ai-as.cc", "sub": "agent_manager", "agent_id": "agent_007" },
"agent_id": "agent_007"
}
| 语义 | 说明 |
|---|---|
sub | 原用户不变(OBO):资源服务器按“被代表的人“隔离与归因 |
act | RFC 8693 §4.1 委托链,最外层 = 当前 actor;只含身份 claims |
agent_id | 扁平便捷 claim,工具/网关直读 |
aud | resource 指示器,未传时继承原 token 的 aud |
permissions | scope 参数与用户权限的交集;未传 scope 时继承用户权限 |
| TTL | 默认 900 秒(DELEGATION_TOKEN_TTL_SECS 可配),不发 refresh |
前置条件
client 须为机密 client 且 grant_types 含 urn:ietf:params:oauth:grant-type:token-exchange(后台 grant_types 标签中勾选 token-exchange)。
链式委托(Agent 的 Agent)
拿委托 token 再换一次,act 自动按规范嵌套(最外层 = 最新 actor,最内层 = 最初 actor):
{
"act": {
"sub": "agent_worker_b",
"act": { "iss": "...", "sub": "agent_manager", "agent_id": "agent_007" }
}
}
消费方做访问控制只看顶层 claims 与最外层 actor,嵌套链仅作审计线索(RFC 8693 §4.1)。
introspect
委托 token 与服务身份 token 均可在 introspect 响应中拿到 act / agent_id:
POST /oauth/introspect
token=<委托 token>
{
"active": true,
"sub": "42",
"username": "alice",
"client_id": "agent_manager",
"act": { "iss": "...", "sub": "agent_manager", "agent_id": "agent_007" },
"agent_id": "agent_007"
}
审计
每次 exchange 落审计事件 TOKEN_EXCHANGED(subject + agent client + agent_id + resource),后台「审计日志」可查。工具级归因配合决策审计(见 Agent 资源授权)构成 人 → Agent → 工具 全链。
设备授权流(RFC 8628)
无浏览器的 MCP 客户端(桌面 / CLI Agent)的登录方式:设备显示用户码,用户在任意浏览器完成确认。
流程
设备 AuthKeystone 用户浏览器
│ POST /oauth/device/authorize ──────────▶│ │
│ ◀── device_code + user_code ────────────│ │
│ │◀── GET /device?user_code ──────│
│ │─── 登录 + 批准/拒绝 ──────────▶│
│ POST /oauth/token (device_code) ─────▶│ │
│ ◀── access_token / authorization_pending│ │
1. 设备发起
POST https://auth.ai-as.cc/oauth/device/authorize
Content-Type: application/x-www-form-urlencoded
client_id=mcp_cli_client
&scope=openid profile auth:permissions
&resource=https://mcp.erp/api # 可选,RFC 8707 → token aud
响应:
{
"device_code": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.ai-as.cc/admin/#/device",
"verification_uri_complete": "https://auth.ai-as.cc/admin/#/device?user_code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}
设备展示 user_code 与 verification_uri(有屏幕的设备直接展示 verification_uri_complete 二维码)。
前置条件:client 的 grant_types 含 urn:ietf:params:oauth:grant-type:device_code(后台勾选 device_code)。公开客户端(CLI)免 secret,走 PKCE 同级的安全性由用户确认环节保证。
2. 用户确认
浏览器打开 verification_uri_complete(或打开确认页手输用户码)→ 登录 → 授权页展示 client 与 scope 明细 → 批准 / 拒绝。
3. 设备轮询
按 interval 秒轮询:
POST /oauth/token
grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=<device_code>
&client_id=mcp_cli_client
| 状态 | 响应(HTTP 400 + error) |
|---|---|
| 等待用户确认 | authorization_pending |
| 轮询快于 interval | slow_down |
| 用户拒绝 | access_denied |
| 设备码过期(600s) | expired_token |
| 已批准 | 200,标准 token 响应(access + refresh + id_token) |
批准后设备码一次性消费,重放返回 invalid_grant。签发的 token:sub = 批准用户,aud = resource(传了的话)否则 client_id。
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:
- 用户映射(统一):身份 =
(provider_id, provider_kind),登录/绑定走user_idp_bindings表(多绑模型,2026-09);users.oauth_provider = "idp:{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 | 各自填 | 各自填 | 各自填 | 配置 | 配置 | 配置 | 配置 |
scope 按平台官方词表填(预置:GitHub read:user user:email、Gitee user_info——Gitee 合法 scope 不含 emails,塞了会 invalid_scope 登录断)。私有邮箱补全不靠 scope,靠 emails_url(邮箱补全端点):userinfo 未返回 email 时回退拉取该端点,只取已验证条目(GitHub verified=true / Gitee state=confirmed,primary 优先);预置 GitHub .../user/emails、Gitee /api/v5/emails。
租户管理员在 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 + emails_url(邮箱补全端点,可空——userinfo 无 email 时回退拉取,只取已验证条目)。
- 通用: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}'(新登录)。两套不冲突,老账号原值不变。2026-09 多绑模型迁移(schema v7)已把两套存量回填进user_idp_bindings:能映射到 provider 记录的按(provider_id, 'oauth2')归一,映射不到的落 legacy 负哨兵;users两列此后由写路径同步维护(投影)。
6. 工作量(1 人估)
| 子项 | 工作量 |
|---|---|
| 表扩展 + 迁移 + 模型字段 | ~1 天 |
| RP 逻辑泛化(start/callback 按 type 分支 + 字段映射 + token 传递) | ~3 天 |
| 公开端点 + UI(sso_providers.vue type 选择 + 条件字段 + 模板) | ~2 天 |
| 测试(oidc 路径不回归 + oauth2 用 Gitee 实测) | ~1 天 |
| 合计 | ~1 周 |
7. 微信小程序登录(2026-09 上线,wechat.rs;接入方请看 WECHAT_MINIPROGRAM 契约文档)
微信是自有协议(非标准 OAuth2/OIDC,无 redirect 授权流),不走上文的 provider 通用流程,独立成模块:
- 配置:org_idp_providers 一行
provider_type='wechat'——client_id=小程序 AppID、client_secret=AppSecret(AES 加密落库);不进网页登录按钮(公开列表端点过滤 wechat 类型)。 - 身份键 = unionid 优先:
subject = unionid(拿不到则 openid),bindings 按(provider_kind='wechat', subject)匹配——「微信里的这个人」跨小程序/公众号是同一账号(provider_id 仅记录来源小程序;schema v9 部分唯一索引保证一人一行)。 - 三端点(公开):
POST /api/auth/wechat/login {code, appid}——code2session 换身份;已绑定直接签 JWT;未绑定不建号,返{status:"unbound", wx_token}(Redis 一次性 5min 票据)。POST /api/auth/wechat/register {wx_token}——快速开通:建无邮箱无密码的微信专属账号(后续可转正绑邮箱)。POST /api/auth/wechat/bind {wx_token, username, password, mfa_code?}——绑老号:凭证验证(login_guard 双维度限流 + TOTP 老号必须带码)→link_identity→ 签老号 token。凭证失败不烧票据可重试。无密码老号(Gitee 建号)明确报错引导(设置密码或改用确认码)。- 确认码绑定(免输密码,2026-09):小程序
POST /api/auth/wechat/bind-code {wx_token}换 6 位码(5 分钟,错 5 次作废)→ 用户在已登录的网页端个人中心「关联小程序」输码POST /api/auth/wechat/confirm-bind {code}(老号登录态即控制权证明,无密码老号同样适用)→ 小程序POST /api/auth/wechat/poll-bind {code}轮询取老号 token 对(票据一次性)。
- 首登不建号的理由:微信无邮箱 → 双侧已验证邮箱合并天然失效,即建号必产生「老用户扫码进空号」;二选一把选择权给用户,不产生双号。
- 安全红线:session_key 拿到即弃(不存不下发);code 一次性;AppSecret 只在服务端。
- 边界:身份归 auth(直调 jscode2session,与 GitHub/Gitee IdP 同构);wechat-publish-service 等下游应用保持自身用户体系不动,可渐进切换。本地 mock:
WECHAT_API_BASE覆盖 API 域名。
7b. QQ 互联(2026-09-05 上线,provider_type=‘qq’)
redirect 流的「微信式特例」——协议形态介于标准 OAuth2 与自有协议之间,callback 走专属分支:
- 配置:管理台「组织 IdP 配置」类型选 QQ 互联——client_id=APP ID、client_secret=APP Key(AES 加密落库);四端点内置免填(
QQ_API_BASE可覆盖供 mock)。QQ 后台的回调地址填https://auth.ai-as.cc/api/auth/sso/callback。 - 协议特例三处(callback 专属分支):token 端点 GET、响应是 form 文本(
access_token=...&expires_in=...);身份键 openid 由独立 me 端点返回(JSONP 剥壳,申请了 unionid 机制的应用带 unionid);userinfo 需附oauth_consumer_key(APPID)+openid、无 email(JIT 建号无邮箱,用户后续自助绑邮箱)。 - 身份家族:
kind='qq',subject = unionid(有则用)或 openid;登录/属主查询按(kind, subject)(对称微信家族)——同一开发者账号下多 QQ 应用 unionid 同号(schema v11 部分唯一索引)。首个应用未申请 unionid 机制时 subject=openid,将来申请后新登录走 unionid(老绑定不动,用户重绑即迁移)。 - 登录页:QQ 按钮出现在网页登录页/绑定卡(与 Gitee 同列,redirect 流)。
8. 边界(本阶段不做)
- ❌ 硬编码 GitHub/Gitee 迁移到 IdP 配置(保留兼容,迁移影响现有用户 oauth_provider 值)。
- ❌
微信/飞书自有协议适配(微信小程序已于 2026-09 落地,见 §7;飞书仍不做)。 - ❌ SAML(仍等大客户要再做)。
- ❌ 租户级硬编码停用(硬编码是平台预置全局,租户不能停;租户只管自己配的 IdP)。
微信小程序登录接入指南(ExoMind 等下游小程序端)
读者:小程序前端开发者。auth 侧实现见
TENANT_UNIFIED_IDP§7;本文只讲你需要的接入契约。 生产 base:https://auth.ai-as.cc。当前已配置的小程序:wx5732c2ddcea11374(AI浪随身工具)。
1. 核心概念(30 秒)
- 静默登录:
wx.login()拿 code,换 auth 的 JWT——用户零输入零点击。 - 首登不建号:auth 第一次见到这个微信身份时不会自动建账号,返回
unbound+ 一张 5 分钟票据(wx_token),由用户选择「快速开通」还是「绑定已有账号」——防止老用户扫码进空号。 - 身份键 = unionid:同一微信用户在小程序/公众号/将来网页扫码下是同一个账号(零绑定操作)。
- token 同源:小程序拿到的 JWT 与网页登录、CLI 的 token 同构——
sub是统一 user_id,数据天然同源(ExoMind 按 sub 组织数据,小程序端看到的就是用户在网页端的那份)。
2. 全流程时序
小程序启动
└─ wx.login() → code
└─ POST /api/auth/wechat/login {code, appid}
├─ status:"bound" → 拿到 token 对,直接进入应用(99% 的日常路径)
└─ status:"unbound" + wx_token(首次登录,5 分钟有效)
├─ 新用户:POST /api/auth/wechat/register {wx_token} → 建号 + token 对
└─ 老用户二选一:
├─ 密码绑定:POST /api/auth/wechat/bind {wx_token, username, password[, mfa_code]}
└─ 确认码绑定(推荐,免输密码):
小程序 POST /api/auth/wechat/bind-code {wx_token} → 6 位码
→ 用户在电脑上已登录的 auth 管理台·个人中心「关联小程序」输码
→ 小程序轮询 POST /api/auth/wechat/poll-bind {code}(2s 间隔)
├─ pending → 继续轮询
├─ bound + token 对 → 进入应用
└─ expired → 提示重来
之后每次启动:wx.login() → login → bound → 直接用。除首次外全程无感。
3. API 参考
所有端点均为 POST、Content-Type: application/json、无需鉴权头(登录前没有 token)。
3.1 POST /api/auth/wechat/login — 静默登录第一步
// 请求
{ "code": "wx.login() 拿到的 code(一次性,用完即废)",
"appid": "wx5732c2ddcea11374",
"audience": "client_exomind" }
audience(可选但强烈推荐):目标下游的 client_id——签出的 token aud 即该值,下游 fail-closed 校验(aud == client_id)直接通过,下游零改动。续期(refresh)全生命周期继承该 aud。校验规则:必须是存在且启用的 OAuth client,否则报 audience 无效。register/bind/bind-code/poll 同样支持该参数,同一小程序内全程传同一个值。不传则 aud = issuer(下游需将 issuer 加入 aud 白名单,见 §5)。
// 响应一:已绑定(日常路径)
{ "success": true, "data": {
"status": "bound",
"login": {
"access_token": "eyJ...", // JWT,调业务 API 用 Bearer 头
"refresh_token": "eyJ...", // 过期续期用(见 3.6)
"user": { "id": 57, "username": "...", "org_id": "default", ... },
"permissions": [...]
}
} }
// 响应二:首次登录(未绑定)
{ "success": true, "data": { "status": "unbound", "wx_token": "wxt_xxx..." } }
错误:微信登录失败(errcode=40029):invalid code(code 无效/已用,重新 wx.login)/ 微信登录未开放(appid 未配置或已停用)(appid 填错)。
3.2 POST /api/auth/wechat/register — 快速开通(新用户)
{ "wx_token": "login 返回的票据" }
成功 → {status:"registered", login:{...}}(建无邮箱无密码的微信专属账号;用户之后可在网页端个人中心补绑邮箱/设置密码)。wx_token 一次性,消费即失效。
3.3 POST /api/auth/wechat/bind — 绑定已有账号(密码方式)
{ "wx_token": "...",
"username": "已有账号的邮箱或用户名",
"password": "该账号的密码",
"mfa_code": "可选;该账号开了双因子时必填 6 位码" }
成功 → {status:"bound", login:{...}}——token 是老号的(数据都在)。密码错误不烧票据可重试(有防爆破限流)。
3.4 POST /api/auth/wechat/bind-code — 确认码绑定·发起(推荐)
{ "wx_token": "..." }
// → { "success": true, "data": { "code": "302641", "expires_in": 300 } }
小程序把 6 位码显示给用户(大字号,提示「在电脑上打开 auth.ai-as.cc → 个人中心 → 关联小程序 输入此码」)。消费 wx_token。
3.5 POST /api/auth/wechat/poll-bind — 确认码绑定·轮询
{ "code": "302641" }
// → { "data": { "status": "pending" } } // 用户还没在电脑上确认,继续轮询(建议 2s 间隔)
// → { "data": { "status": "bound", "login": {...} } } // 确认完成,票据同时消费(一次性)
// → { "data": { "status": "expired" } } // 5 分钟超时/错 5 次作废,提示重新发起
3.6 配对码 Web 登录(电脑 ↔ 小程序)
让用户在电脑网页上用微信完成登录:方案A 手输码(小程序出 8 位码,电脑输码)与 方案C 扫码(电脑出二维码,小程序扫码确认)。两条路径共用一次性配对码与同一换 token 落点(3.6.5);会话与码均存 Redis、5 分钟有效;确认幂等;同一用户重复出码自动作废旧码。
| 端点 | 调用方 | 鉴权 |
|---|---|---|
POST /api/auth/wechat/pair-code | 小程序(方案A 出码) | Bearer token |
POST /api/auth/wechat/pair/qr/start | 电脑登录页(方案C 出码) | 公开 + IP 限流 |
POST /api/auth/wechat/pair/qr/status | 电脑登录页轮询 | 公开 + IP 限流 |
POST /api/auth/wechat/pair/qr/confirm | 小程序扫码确认页 | Bearer token |
POST /api/auth/wechat/pair-exchange | 电脑登录页(两方案共用) | 公开 + IP 限流 |
3.6.1 POST /api/auth/wechat/pair-code — 出 8 位配对码(方案A)
鉴权层(匿名拒绝;Bearer 小程序 token),无请求体:
// → { "success": true, "data": { "pair_code": "30264197", "expires_in": 300 } }
小程序把 8 位码大字号显示给用户:「电脑上打开登录页 → 微信登录 → 手动输码」。
3.6.2 POST /api/auth/wechat/pair/qr/start — 生成登录二维码(方案C)
{ "app_id": "可选;多小程序时指定目标 appid" }
// → { "success": true, "data": { "session_id": "…", "qr_image": "data:image/png;base64,…",
// "expires_in": 300, "page": "pages/web-login/index" } }
qr_image 直接渲染 <img>;page 为小程序确认页路径(服务端 env WX_PAIR_QR_PAGE 可配)。微信侧出码失败 → 500(message 含微信 errcode),登录页降级到 8 位码入口。
3.6.3 POST /api/auth/wechat/pair/qr/status — 电脑轮询(方案C)
{ "session_id": "…" }
// → { "data": { "status": "pending" } } // 未确认,建议 2s 间隔
// → { "data": { "status": "approved", "pair_code": "30264197" } } // 手机已确认,拿码走 3.6.5
session 过期/不存在 → 400「二维码已过期…」(AppError::Validation,error.rs),前端重新 start 刷新二维码。
3.6.4 POST /api/auth/wechat/pair/qr/confirm — 小程序扫码确认(方案C)
鉴权层(Bearer 小程序 token)。小程序从 scene 解析 session_id(decodeURIComponent(scene) → ps=<session_id>),先走 3.1 静默登录拿 token,再调:
{ "session_id": "…" }
// → { "data": { "status": "approved", "pair_code": "30264197", "expires_in": 180 } }
// expires_in 为会话剩余秒数;重复确认幂等,返回同一票据
pending → approved 并出一次性 8 位码;400 = 会话过期,提示「请在电脑刷新后重新扫码」。确认页注意防钓鱼心智:页面标题明示目标站点,禁用转发分享。
3.6.5 POST /api/auth/wechat/pair-exchange — 配对码换 token(两方案共用落点)
{ "pair_code": "30264197" }
// → data 即标准 LoginResponse,形状与 /api/auth/login 完全一致
8 位数字校验;一次性消费(校验前即删,失败不可重试同一码)。注意:本接口不支持 audience 参数,签出的 token aud = issuer——下游校验按 §5 方式 B(aud 白名单)适配。
3.7 token 的使用与续期
- 调 ExoMind 等业务 API:
Authorization: Bearer <access_token>(ExoMind 按 token 的sub识别用户,数据与网页端同源)。 - access_token 有效期 24h;过期用 refresh_token 换新(复用标准端点):
POST /api/auth/refresh { "refresh_token": "eyJ..." }
- 存储:小程序本地安全存储;登出调
POST /api/auth/logout。
4. 小程序端参考实现
const AUTH = 'https://auth.ai-as.cc'
const APPID = 'wx5732c2ddcea11374'
async function request(path, data) {
const r = await wx.request({ url: AUTH + path, method: 'POST',
data, header: { 'Content-Type': 'application/json' } })
if (r.statusCode !== 200 || !r.data.success) throw new Error(r.data.error || '网络错误')
return r.data.data
}
async function login() {
const { code } = await wx.login()
const d = await request('/api/auth/wechat/login', { code, appid: APPID })
if (d.status === 'bound' || d.status === 'registered') return d.login // 直接可用
// 首次:返回给 UI 层弹出二选一(携带 d.wx_token)
return { unbound: true, wxToken: d.wx_token }
}
async function quickRegister(wxToken) { // 新用户「快速开通」
const d = await request('/api/auth/wechat/register', { wx_token: wxToken })
return d.login
}
async function startConfirmBind(wxToken) { // 老用户「电脑确认码」
const d = await request('/api/auth/wechat/bind-code', { wx_token: wxToken })
wx.showModal({ title: '在电脑上确认绑定',
content: `打开 auth.ai-as.cc 登录后,进入「个人中心 → 关联小程序」,输入确认码:${d.code}(5 分钟内有效)` })
return new Promise((resolve, reject) => {
const timer = setInterval(async () => {
try {
const p = await request('/api/auth/wechat/poll-bind', { code: d.code })
if (p.status === 'bound') { clearInterval(timer); resolve(p.login) }
if (p.status === 'expired') { clearInterval(timer); reject(new Error('确认码已过期,请重新发起')) }
} catch (e) { clearInterval(timer); reject(e) }
}, 2000)
})
}
5. 调下游 API(ExoMind 等)——token 形态与下游校验
小程序拿到的 access_token 直调下游(Authorization: Bearer),下游验签有一处必须适配:
token payload 解剖
{
"iss": "https://auth.ai-as.cc",
"aud": "https://auth.ai-as.cc", // ⚠️ 第一方用户 token:aud=issuer,不是下游 client_id
"sub": "57", // 统一 user_id(与网页端登录同一账号同一 sub)
"permissions": ["read", "write", ...], // 用户权限码(下游按码判断,如 /ingest 要求 write)
"exp": 1735689600, "jti": "..."
}
- 签名 RS256、密钥与网页授权码流程同一把(
/.well-known/jwks.json,header 带 kid)。 - 两种通过下游校验的方式(二选一,推荐 A):
- A(推荐):登录请求带
audience: "<下游 client_id>"——tokenaud即下游 client_id,下游现有 fail-closed 校验直接通过,下游零改动;refresh 续期不掉受众(2026-09-05 上线)。 - B:下游把 issuer 加入 aud 白名单(
aud ∈ {client_id, "https://auth.ai-as.cc"})——适用于不便传 audience 的存量接入。详见 INTEGRATION §5。
- A(推荐):登录请求带
permissions 从哪来(治理依赖)
| 用户 | token 里的 permissions |
|---|---|
| 绑定老号 | 老号的全量权限码 |
| 快速开号新号 | 注册默认角色集(管理台「系统设置 → 注册默认角色」配置,未配置回退 user 角色)+「Agent 开发者」角色(2026-09-10 并入默认链) |
下游必备权限码(如 write)必须包含在注册默认角色集里,否则新用户调写入接口 403——这是配置态不是代码保证,改默认角色前先核对下游依赖。当前生产默认角色集含「ExoMind读写」(read/write)。
2026-09-10 起「Agent 开发者」并入注册默认链(注册/匿名转正/SSO/管理台建号四条路径自动获得,详见 AGENT_AUTHZ「授权域角色开放」):新号 token 的 permissions = 默认角色集 ∪ agent 域权限码(agent:resource:manage 等四码),不影响下游对 read/write 的判断。
冒烟验收(下游联通的判定步骤)
1. 小程序 wx.login → login → (首登)register → 拿 access_token
2. curl -X POST https://youhuale.cn/ingest \
-H "Authorization: Bearer <access_token>" -H "Content-Type: application/json" \
-d '{"content":"冒烟测试"}'
期望 200;401 → 查下游 aud 白名单;403 → 查注册默认角色是否含 write
401/403 排查表
| 症状 | 原因 | 修法 |
|---|---|---|
| 401(下游验签失败) | token aud=issuer 而下游只认 client_id | 登录请求加 audience(方式 A,推荐);或下游 aud 白名单加 issuer(方式 B) |
| 401(token 过期) | access_token 24h | refresh_token 换新(§3.7) |
| 403 | permissions 缺下游要求的码 | 管理台默认角色集补对应权限码(老号走角色提升申请) |
| 401(auth 侧) | 会话被踢(改密/解绑等) | 重新走 login |
6. 约束与红线(接入方必读)
| 项 | 说明 |
|---|---|
code 一次性 | 每个 wx.login code 只能换一次,失败重试要重新 wx.login |
wx_token 一次性、5 分钟 | register/bind/bind-code 消费即失效;过期让用户重新走 login |
| 确认码错 5 次作废 | 用户在电脑上输错 5 次需重新发起 |
| session_key | auth 侧不使用不下发——小程序端也不要存它(微信红线) |
| AppSecret | 只存在 auth 服务端(管理台 provider 配置),小程序端永远接触不到 |
| 限流 | login 有 IP 维度限流;bind 密码错误有防爆破锁定——收到 429/锁定文案就退避提示用户 |
7. FAQ
Q:换小程序(新 appid)用户数据怎么办? 同一微信开放平台账号下,unionid 不变 → 用户身份不变;管理台为新 appid 加一条 provider 配置即可,用户无感。
Q:用户换了微信号? 新微信号 = 新身份(首次登录会再走二选一)。老绑定不自动迁移——这是身份语义,不是 bug。
Q:绑定错了账号想换绑? 用户在 auth 个人中心解绑微信(带参 unbind),下次小程序登录重新走二选一。
Q:接口报「微信登录未开放」? appid 与管理台 provider 配置的 client_id 不一致,或该 provider 被停用。
阶段 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, ...}
⑦用户映射:身份 = (provider_id, 'oidc'),写 user_idp_bindings(多绑模型);users 两列投影 "oidc_inbound:{provider_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)+INSERT user_idp_bindings(双轨,2026-09 起) - 后续:
SELECT user_id ... FROM user_idp_bindings WHERE provider_id=? AND subject=?命中即登录(users 两列仅为投影)。 - 登录后签的 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 默认能力,不自建)。