Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/server/docs/ai-context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
- 账号注销架构:auth 表 hard delete + 业务表软删,handler 协议、各业务行为、failure 模型
- `admin-flux-grants.md`
- Admin 批量发 FLUX(活动赠送):单一同步 POST,无 batch 表无后台 loop,`adminGuard` 邮箱白名单 + 可选 `idempotencyKey`
- `account-ban.md`
- Admin 授权改 role-based(better-auth `admin` 插件,删 `ADMIN_EMAILS`);ban/unban 收敛到 better-auth 原生端点(`user.banned`),`resolveRequestAuth` + userinfo guard 做 OIDC JWT 热路径立即生效;改余额(`setFlux`)保留自建;含 disabledPaths 端点收口与「dashboard 为何不上」的决策
- `verifications/email-auth.md`
- 邮箱注册 / 忘记密码 / OIDC 桥接登录 三条用户路径的真实实测证据
- `verifications/account-deletion.md`
Expand All @@ -51,6 +53,8 @@
- Unpaid-usage exploit 修补(commit `7267b0d6b`)的代码层验证 + 残余 gap(TTS flux-meter 未适配 partial-debit)+ follow-up 清单
- `verifications/flux-unbilled-reconciliation.md`
- 70.2K 历史漏账的取证 SQL + Loki query 模板、处理决策框架、修补后的监控建议
- `verifications/admin-user-balance-ban.md`
- Admin role 鉴权 / 封禁热路径闸 / 改余额:role adminGuard、resolveRequestAuth+userinfo 封禁、setFlux 的真实 PGlite/Hono 执行证据(含 flux-grants 集成测试走 role),及 better-auth admin 端点本身待端到端实测

## 快速结论

Expand Down
61 changes: 61 additions & 0 deletions apps/server/docs/ai-context/account-ban.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Admin Access (Role) + Account Ban + Balance Override

服务端的 admin 能力统一在 better-auth 内置 `admin` 插件的 **role** 体系下,不再用 `ADMIN_EMAILS` 环境变量白名单。

## 授权模型:role-based

- `auth.ts` 启用 `admin({ adminRoles: ['admin'] })`。它给 `user` 表加 `role / banned / banReason / banExpires`,给 `session` 加 `impersonatedBy`(schema 手写进 `schemas/accounts.ts`,字段名与插件一致,迁移 `drizzle/0013_naive_groot.sql`)。
- 自建的 `/api/admin/*` 路由用 `middlewares/admin-guard.ts` 的 `adminGuard`:读 `c.get('user').role`,命中 `'admin'`(支持逗号分隔多角色)才放行,否则 401(无 user)/ 403(无 admin role)。
- **没有 env 白名单,也没有自动 seed**。第一个 admin 手动设:`UPDATE "user" SET role = 'admin' WHERE email = '...';`。在那之前没人能访问任何 admin 端点(自建的和 better-auth 的都不行)。

## better-auth admin 端点(收敛后的 ban/unban 在这里)

- 账号封禁/解封用 better-auth 原生端点:`POST /api/auth/admin/ban-user`、`/api/auth/admin/unban-user`(body 用 `userId`,可带 `banReason` / `banExpiresIn`)。调用者需要 admin role(插件内部 `hasPermission` 校验)。
- ban 会写 `user.banned = true` 并 `deleteSessions(userId)`。`banExpires` 到点后,插件在下次登录的 `session.create.before` 自动翻回 `banned = false`。
- **危险端点用 `disabledPaths` 关掉**(`auth.ts`):`create-user / update-user / set-role / set-user-password / remove-user / impersonate-user / stop-impersonating`。只留读 + ban/unban + session 管理子集(list-users / ban-user / unban-user / list-user-sessions / revoke-user-session(s) / get-user / has-permission)。
- `set-role` 也关了:role 授予走手动 DB,不开放 HTTP 提权面。

## 封禁怎么「立即生效」

admin 插件的封禁强制只在 `session.create.before`(拦新登录)。但 stage-web / electron / pocket 热路径带的是 oauthProvider 签的无状态 RS256 JWT,`resolveJWTAccessToken` 只验签 + `findUserById`,**不建 session、不查 session 行**,插件那个钩子根本不触发。

所以热路径的封禁判断自己做,落在 `resolveRequestAuth`(所有传输层唯一鉴权入口:`sessionMiddleware` / 两个 WebSocket / OIDC `get-session`):

- 解析出 user 后调 `isUserBannedNow(user)`(`libs/request-auth.ts`),命中返回 `null` → 上层当未鉴权(401)。
- `isUserBannedNow` 读的是 `user.banned`(`findUserById` 已经把整行 user 带回来了,**零额外查询**),并判 `banExpires`:过期的 ban 当未封禁。
- `findUserById` 的 TS 返回类型是 better-auth 基础 User,不含插件字段,但运行时整行都在 → `request-auth.ts` 有一处带 `// NOTICE:` 的 widen cast 拿回 `banned`。

另外 `/api/auth/oauth2/userinfo` 单独加了一道 guard(`routes/auth/index.ts`):`/api/auth/*` 绕过 `sessionMiddleware`,而 userinfo 只验签就返 profile,所以这里用 `resolveSessionIgnoringBan` + `isUserBannedNow` 拦被封用户的有效 JWT。`/oauth2/introspect` 要 confidential client(一方 client 全 public),无可达调用方,不补。

**封禁时撤销 OAuth 凭据**(codex review 发现并修):admin 插件 `banUser` 只删 session,留着 `oauth_refresh_token` / `oauth_access_token`。oauthProvider 的 `/oauth2/token` refresh grant(`@better-auth/oauth-provider/dist/index.mjs:718`)加载 user 但**不查 `banned`**,所以被封用户本可用现存 refresh token 换一个全新 JWT。那个新 JWT 在所有资源路径仍被 `isUserBannedNow` 挡住(拿不到实际访问),但为了从源头断掉,`auth.ts` 加了 `databaseHooks.user.update.after`:检测 `banned=true` 时删该用户的 oauth refresh/access token 行(`banUser` 通过 `updateUser` 写 banned,触发此 hook)。这样 refresh grant 自然失败。

## 余额设定(无 better-auth 对应物,保留自建)

改余额是 flux 领域操作,better-auth admin 没有,保留为自建路由 `POST /api/admin/users/balance`,用 `adminGuard`(role)守。

- `AdminUsersService.setBalance` 把 selector(email | userId 二选一,`requireSingleSelector` 强校验)解析成 userId(`resolveUserByIdOrEmail`),再委托 `BillingService.setFlux`。
- `setFlux` 单事务锁 `user_flux` 行 → 改余额 → 写 `flux_transaction`(type `admin_set`,方向 + before/after + issuedBy 进 metadata)→ 提交后 `redis.del` 失效缓存(不是写新值,避免参与 credit/debit 已有的跨操作 cache 竞态,详见下)。

flux-grants(`/api/admin/flux-grants`)和 router-config(`/api/admin/config/router`)同理:保留自建,鉴权从 `adminGuard(env)` 换成 role-based `adminGuard`。

## 相关文件

- `src/libs/auth.ts` — `admin()` 插件 + `disabledPaths`
- `src/schemas/accounts.ts` — user/session 上的 admin 插件字段
- `src/middlewares/admin-guard.ts` — role-based `adminGuard`
- `src/libs/request-auth.ts` — `isUserBannedNow` + 热路径封禁闸
- `src/routes/auth/index.ts` — `/oauth2/userinfo` 封禁 guard
- `src/routes/admin/users/index.ts` — `POST /balance`(自建)
- `src/services/domain/admin/users/index.ts` — `setBalance` 编排
- `src/services/domain/billing/billing-service.ts` — `setFlux`

## 余额缓存竞态(预先存在)

`creditFlux` / `debitFlux` / `setFlux` 的 Redis 写都在事务提交后、行锁外,无版本号。多实例并发下较慢的旧 SET 可能后到覆盖新值。这不是本次引入的,`setFlux` 用 `redis.del`(失效,对齐 `FluxService.deleteAllForUser`)而非 SET,至少不往里添 stale SET。彻底修需要给三个写统一加版本化缓存写,超出范围。`getFlux` 是 cache-aside,stale 窗口下次 miss 自愈,Postgres 始终是真相。

## 已知边界 / 取舍

- **第一个 admin 必须手动设 role**(删了 `ADMIN_EMAILS`)。首次部署/新环境要手动 `UPDATE "user" SET role='admin'`
- **ban 是 userId 单维**:账号在时 userId ban 已堵死一切登录方式(邮箱、所有 OAuth 都 resolve 到同一 user)。不覆盖「账号被删后用同邮箱/OAuth 重注册」——被封用户登不进去也删不了号,该洞只在 admin 主动删号后出现
- **dashboard 没上**:`@better-auth/infra` 的 `dash()` 是闭源 SaaS(`dash.better-auth.com`)的服务端 SDK,会把认证/用户数据外发第三方、`/dash/execute-adapter` 远程驱动 DB,且 `DashOptions` 只有 `activityTracking`、塞不进我们的 flux 业务页面。决定:业务管理(flux / grants / router config)将来自建 admin UI,user/session 管理直接调 better-auth admin 端点,不引入外部 SaaS
- admin 插件的 ban-user 端点 + `session.create.before` 登录拦截属于库行为,未跑真实 better-auth 登录流端到端验(靠源码确认 + 我们的热路径闸有真实执行覆盖)。详见 `verifications/admin-user-balance-ban.md`
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Verification: Admin 授权(role) + 封禁 + 改余额

- 环境:commit `6f63ce96e`(工作区改动未提交),本地 vitest + PGlite 内存 Postgres
- 最后验证日期:2026-05-26
- 模型:admin 授权改 better-auth `admin` 插件的 **role**(删 `ADMIN_EMAILS`);ban/unban 收敛到 better-auth 原生 `user.banned`;改余额保留自建。设计见 `../account-ban.md`

## 已验证(fresh execution)

命令:

```sh
pnpm -F @proj-airi/server typecheck # tsc --noEmit,无输出(通过)
pnpm exec vitest run apps/server # 全量 Test Files 43 passed / Tests 398 passed(含 tests/ 集成测试)
pnpm db:generate # → drizzle/0013_naive_groot.sql
pnpm -F @proj-airi/server-schema build # 重新打包迁移
```

### 用户路径 → 预期 → 实测

1. role-based adminGuard(HTTP 边界)
- 预期:无 user → 401;role 非 admin / 无 role → 403;role='admin'(含逗号分隔 'user,admin')→ 放行
- 实测:`middlewares/tests/admin-guard.test.ts` 5 个用例通过

2. admin flux-grants 走 role 鉴权(真实 app + PGlite 集成)
- 预期:session user role='admin' → 200 grant;无 role → 403;无 session → 401
- 实测:`tests/verifications/admin-flux-grants.integration.test.ts` 通过,经 `buildApp` 全量装配 + 真实 PGlite(user 表带新 role 列),adminGuard 读 `user.role` 判定

3. 封禁立即生效(热路径,OIDC JWT)
- 预期:`resolveRequestAuth` 对 `user.banned` 命中的 principal 返回 null(即使 session 能解析);`banExpires` 已过期当未封禁
- 实测:`libs/tests/request-auth.test.ts`「rejects a banned principal even when the session resolves」「treats an expired ban as not banned」通过

4. userinfo 封禁 guard(HTTP 边界)
- 预期:被封 subject 打 `/api/auth/oauth2/userinfo` → 403 不到 handler;未封禁透传;ban 过期透传
- 实测:`routes/auth/oidc-userinfo-ban.test.ts` 3 个用例通过,挂真实 `createAuthRoutes` 装配,banned 由 mock 的 getSession user 驱动

5. 改余额为 0 / 任意值(含缓存失效)
- 预期:`user_flux.flux` 覆盖,写 `admin_set` 账本(direction + before/after + issuedBy),提交后 `redis.del` 失效缓存
- 实测:`services/domain/billing/tests/billing-service.test.ts` setFlux 三个用例通过(设值 / 归零 / 初始化行 + `redis.del` 断言)

6. 余额 route 鉴权 + 校验(HTTP 边界)
- 预期:未登录 401 / 非 admin role 403 / 负余额 400 / selector 非恰好一个 400 / 正常委托并回传
- 实测:`routes/admin/users/route.test.ts` 6 个用例通过

7. 迁移
- `drizzle/0013_naive_groot.sql`:`ALTER user ADD role/banned/ban_reason/ban_expires` + `ALTER session ADD impersonated_by`,无 account_ban
- server-schema 重打包成功

8. 封禁撤销 OAuth 凭据(codex review 发现)
- 预期:ban 时(`updateUser` 写 banned=true)触发 `databaseHooks.user.update.after`,删该用户 oauth refresh/access token,堵住「refresh grant 换新 JWT」
- 状态:已加 hook + `// NOTICE:`,typecheck/lint 通过。hook 真正触发依赖 better-auth `updateUser` 全流程,属下方 pending(同 admin 端点真实登录流)。新 JWT 即便被 mint 也已被 isUserBannedNow 在第 3、4 条覆盖的资源路径挡住

## 待实测(pending)

- better-auth `admin` 插件自身的 `/api/auth/admin/ban-user|unban-user` 端点 + `session.create.before` 登录拦截未跑真实 better-auth 登录流(需起真服务 + Postgres + OAuth)。靠源码确认语义;我们自己的热路径闸(resolveRequestAuth / userinfo guard)已被第 3、4 条真实执行覆盖
- 真实部署:第一个 admin 需手动 `UPDATE "user" SET role='admin' WHERE email='...'`;多实例 Railway + 真实 Redis 下的封禁延迟未测

## 设计取舍(不再覆盖)

- 删 `ADMIN_EMAILS`:首次部署/新环境要手动设 role
- ban userId 单维:不覆盖「删号后用同邮箱/OAuth 重注册」(见 `../account-ban.md`)
- dashboard 未上:`@better-auth/infra` dash() 是闭源 SaaS + 数据外发 + 不可扩展,业务管理将来自建 UI
5 changes: 5 additions & 0 deletions apps/server/drizzle/0013_naive_groot.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "role" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "ban_reason" text;--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "ban_expires" timestamp;
Loading
Loading