Files
ZY-Agent/docs/multi-tenant-redesign/03-impl/pr4-auth-workspace.md
T
1445043649 44f84800e9 docs(impl): PR4 implementation note + STATUS update
Records the 14-task PR4 delivery (T4.1-T4.14), final test baseline
(3136 passed / 26 skipped), key design decisions that diverged from
the plan draft (TokenPayload optional fields, ensure_default_workspace
helper shape, slug walker treating blacklist as taken), follow-up
items, and a copy-paste live smoke command list for the user to
verify make-dev end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:43:04 +08:00

9.0 KiB
Raw Blame History

PR4 · 注册改造 + JWT 加 wid + AuthMiddleware ContextVar 注入 + /auth/me + alembic 0001

实现笔记。对应 docs/superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md PR4T4.1-T4.14)。

状态:已落地13 个 feat/test commits + 1 个 docs commit 提交到 docs/multi-tenant-redesignd98498b7..5c7753c0)。

范围

PR4 把 PR3 的 schema 接到真实的 auth 流程上:每个新注册用户立刻获得 1 人 workspace + owner membershipJWT 携带 wid + role;中间件把 workspace 塞进 request-scoped ContextVar/auth/me 返回 memberships 列表。同时落 alembic baseline0001 — users.default_workspace_id)。

不在范围(后续 PR):

  • ALTER threads_meta / runs / feedbackworkspace_idPR5
  • 入口路由 (wid, tid) 校验 + Paths 切 workspace 维度(PR6
  • 前端 picker / role 真开放 admin/member / invitation 流程(Stage 1/2

验收

  • 38 个新单测全过4 alembic + 2 jwt_workspace + 2 legacy_token_compat + 13 (existing test_auth/test_langgraph_auth/test_auth_errors 更新) + 4 middleware_workspace + 16 workspace_slug + 3 register_creates_workspace + 2 change_password_keeps_wid + 2 auth_me_returns_workspaces + 3 ensure_admin_user_workspace_backfill
  • 全套 pytest tests/ 3136 passed + 26 skipped + 0 新 failPR3 末 3134 + 25);预存的 16 个 caplog 排序 flake 与 PR4 无关(已通过 stash 实验确认)
  • alembic 0001 在 SQLite + Postgres testcontainers 上 upgrade/downgrade 双驱动都过(PG 用例自动 skip 当 docker 未起)
  • JWT 新合同:legacy 4 字段 token → TokenError.WORKSPACE_MISSING → middleware 401 with code WORKSPACE_REQUIRED;新 token 解码 → TokenPayload(wid, role)
  • 注册流程端到端 (test_register_creates_workspace.py)POST /initialize / /register 都建 workspace + owner membership + 设 users.default_workspace_id + JWT 含 wid
  • /auth/me 返回 workspaces[{id, name, slug, role}] + default_workspace_id
  • Slug 算法:auto_slug_from_email 已知输入确定性映射;-2/-3 冲突回退;32 字符限制时 base 自动截断;黑名单 slug(如 admin)被 walker 当成"已占用"自动跳到 admin-2
  • Pre-PR4 admin upgrade path_ensure_admin_user lifespan hook idempotent 回填 workspace
  • 真机 make dev smoke — 待用户agent 无法实际起 gateway daemon

关键决策(与 plan §横切 + LOCK 清单对齐)

选择 理由
TokenPayload wid / role 字段类型 str | None = Noneoptional 在模型层) 既保留 legacy token 解码能力(用于在 decode_token 中 explicit reject)又不让现有调用者全栈崩溃;wid 的强制性靠 decode_token + middleware 实现,不靠 pydantic 字段
WorkspaceMissingError 实现 TokenError.WORKSPACE_MISSING 枚举值 + AuthErrorCode.WORKSPACE_REQUIRED 映射 plan 写"new error class"但实际语义跟 enum 一致;用 enum 与现有 TokenError 风格保持一致
EXPIRED 优先级 jwt.decode 抛 ExpiredSignatureError 时立即返回 EXPIRED,不进 wid 检查 过期 token 的修复路径是 refresh,不是 select-workspace;优先级搞错会把用户引到错误流
create_access_token 签名 wid / role 作为 keyword-only 参数(在 * 后);老 positional token_version 不变 让前 PR4 的 ~15 个 call site(生产路由 + 测试)继续可用;T4.6 才把测试 call site 一次性更新到带 wid
ContextVar 注入位置 auth_middleware.dispatch 内,紧跟 set_current_user 与 user_context 同生同死;try/finally 统一 reset
ActiveWorkspace 形态 auth.models.ActiveWorkspace(id, role) 即满足 CurrentWorkspace Protocol 不需要完整 WorkspaceRowavoid 多一次 DB 查询拿 name/slug
request.state.auth_payload 中转 get_current_user_from_request 把 decoded payload stash 到 request.state middleware 避免二次 decode;显式管道比隐式重做便宜
Slug 黑名单处理 walker 的 exists_check 把 SLUG_BLACKLIST 当作"已占用"自然跳过 不需要在 auto_slug_from_email 里硬塞黑名单依赖;让黑名单留在 persistence 层
SLUG_BLACKLIST 可见性 提升为 public(去掉前导下划线) 注册流程要读,rename 是单次成本
auto_slug_from_email 落点 app/gateway/auth/workspace_slug.py(不在 persistence "email → slug" 是 registration 时序概念,不属于通用仓储
ensure_default_workspace helper routers/auth.py 模块顶层,public name 跨 4 个 router 入口(initialize/register/login/change_password+ 1 个 lifespan 入口复用;idempotent — 已有 default_workspace_id 时 short-circuit
Login 也带 wid login_localensure_default_workspace pre-PR4 用户登录时自动回填;不依赖 lifespan 完成
change_password 重签 token bump ver 同时带 wid + role 不带就把刚改完密码的用户立刻锁出去
/auth/me 响应模型 UserMeResponse(不是改 UserResponse UserResponse 在其他地方也用;扩展专用 response 避免影响其它接口

跟进项(不在 PR4 范围)

  • PR5 alembic 0002ALTER threads_meta / runs / feedback / run_eventsworkspace_id + 回填 legacy_workspace 哨兵 + 改 NOT NULL
  • PR6 仓储 / 路由层 workspace_id 校验:@require_permission 升级、Paths 切 workspace 维度、文件迁移脚本
  • request.state.auth_payload 这条隐式通道:考虑改为更显式的 dataclass attached at known key
  • Regular user pre-PR4 backfill:目前只在 _ensure_admin_user 回填 admin;如果生产有大量预存 regular user 没 workspace,可加一个 batch backfill 脚本(暂不需要,login 路径已 lazy backfill
  • 16 个 pre-existing caplog flake:与 PR4 无关但仍存在;future 集中清理一次

涉及文件

类别 文件
新增 backend/packages/harness/deerflow/persistence/migrations/versions/0001_users_default_workspace.py
新增 backend/app/gateway/auth/workspace_slug.pyauto_slug_from_email + next_available_slug
新增 tests test_alembic_default_workspace_id.py, test_auth_jwt_workspace.py, test_legacy_token_compat.py, test_auth_middleware_workspace.py, test_workspace_slug.py, test_register_creates_workspace.py, test_change_password_keeps_wid.py, test_auth_me_returns_workspaces.py, test_ensure_admin_user_workspace_backfill.py
修改 (harness) persistence/user/model.py(加 default_workspace_id FK, persistence/workspace/sql.pySLUG_BLACKLIST public
修改 (app) auth/jwt.py, auth/errors.py, auth/models.pyUser.default_workspace_id + ActiveWorkspace + UserMeResponse + UserMeWorkspace, auth/repositories/sqlite.py(持久化 default_workspace_id, auth_middleware.py(注入 workspace contextvar, deps.pystash payload, routers/auth.pyensure_default_workspace + 4 路由集成), app.pylifespan backfill
修改 tests (compat) test_auth.py, test_langgraph_auth.py, test_auth_errors.py13 处 create_access_tokenworkspace_id="ws-test"

测试矩阵

测试文件 用例数 覆盖
test_alembic_default_workspace_id.py 42 SQLite + 2 PG upgrade / downgrade round-trip
test_auth_jwt_workspace.py 2 JWT 编码 wid/role + decode 还原
test_legacy_token_compat.py 2 4 字段 token → WORKSPACE_MISSINGexpired 优先级
test_auth_middleware_workspace.py 4 wid contextvar 注入 / 401 WORKSPACE_REQUIRED / 公共路径放行 / try-finally reset
test_workspace_slug.py 16 7 已知输入 + 6 fallback + 3 collision walker
test_register_creates_workspace.py 3 /initialize + /register + slug collision 隔离
test_change_password_keeps_wid.py 2 change_password 重签 + login JWT 含 wid
test_auth_me_returns_workspaces.py 2 workspaces[] 字段 + 用户隔离
test_ensure_admin_user_workspace_backfill.py 3 首次回填 / idempotent / 已有 workspace 跳过

总计:38 个 PR4 新测试 + 13 个改写的现有测试。

Live smoke 命令(用户跟进)

# 1. clean restart
make stop && make dev

# 2. initialize admin
curl -s -X POST http://localhost:8001/api/v1/auth/initialize \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"Str0ng!Pass99"}' | jq

# 3. 看 cookie 含 access_token + csrf_token;解码 access_token 看 wid 是否存在
# 4. 调 /auth/me
curl -s http://localhost:8001/api/v1/auth/me -b /tmp/cookies | jq

# 5. SQL 验证(连 PG
SELECT id, email, default_workspace_id FROM users;
SELECT id, name, slug, owner_id FROM workspaces;
SELECT workspace_id, user_id, role FROM workspace_memberships;

预期:1 user / 1 workspaceslug = admin-2 因为 "admin" 在黑名单)/ 1 owner membership。