diff --git a/docs/multi-tenant-redesign/03-impl/STATUS.md b/docs/multi-tenant-redesign/03-impl/STATUS.md index 92df0414..496d981f 100644 --- a/docs/multi-tenant-redesign/03-impl/STATUS.md +++ b/docs/multi-tenant-redesign/03-impl/STATUS.md @@ -2,11 +2,11 @@ > **每完成 1 个 PR 后必更新**。本文是 Stage 0 唯一的"现在到哪了"权威来源——其它文件(plan、ADR、各 PR impl note)都是静态的,不反映执行进度。 > -> 上次更新:2026-05-14,PR7 merge 进 docs branch 后 +> 上次更新:2026-05-14,PR8 merge 进 docs branch 后(Stage 0 工程全合) ## 一句话状态 -PR1 + PR2 + PR3 + PR4 + PR5 + PR6 + **PR7** 已 merge。**PR7 (2026-05-14)** 落地:第二条 boundary 围栏——`tests/test_workspace_boundary.py` AST 静态扫描 backend 全树,命中 `langgraph.checkpoint.*` / `langgraph_checkpoint_postgres` / `langgraph_checkpoint_sqlite` 且不在 `tests/boundary_allowlist.toml`(4 个合法 importer:`threads.py` + `async_provider.py` + `provider.py` + `runs/worker.py`)即 fail;TYPE_CHECKING-only import 自动豁免(parent-walk 检测 `if TYPE_CHECKING:` 嵌套);9 个 self-test 防止扫描器静默空跑;T7.4 反注入实验把违规一行加进 `feedback.py:13` → scanner 精准红灯 → revert 后即绿。**3241 passed + 31 skipped + 18 caplog flake**(PR6 末 3214 + 30 + 17;+27 passed / +1 skip / +1 flake,PR7 新增 10 个 test,flake delta 与 PR7 改动无关)。Stage 0 仅剩 **PR8(service_accounts / api_keys / external_users schema)**。 +**PR1-PR8 全部已 merge——Stage 0 工程层面收尾。** **PR8 (2026-05-14)** 落地:3 张新表 schema-only 为 Stage 1 headless API 准备底座——`service_accounts`(workspace-scoped 非人身份,`identity_mode` 三态:`collapsed` / `external_passthrough` / `both`,`created_by` FK RESTRICT)/ `api_keys`(service_account 凭证,`key_prefix` 全局 UNIQUE + 双驱动部分索引 `idx_api_keys_active` WHERE `revoked_at IS NULL`,`scopes` 用 String(1024) 不用 PG `text[]` 保 SQLite 兼容)/ `external_users`(passthrough 终端身份,复合 UNIQUE `(service_account_id, external_id)`,`workspace_id` 冗余存储加速聚合)。FK 行为:workspace/SA delete CASCADE、creator user delete RESTRICT。9 新单测(3 service_account + 3 api_key + 2 external_user + 1 反向 metadata registration)。**3250 passed + 31 skipped + 18 caplog flake**(PR7 末 3241 + 31 + 18;+9 passed,flake 数 0 增)。Stage 0 工程层面**仅剩用户跟进的 live smoke**(见下);业务层面看 "Stage 0 退出 Go/No-Go"。 ## 8 PR 状态表 @@ -20,9 +20,12 @@ PR1 + PR2 + PR3 + PR4 + PR5 + PR6 + **PR7** 已 merge。**PR7 (2026-05-14)** 落 | **PR5** | ✅ merged | 11 (T5.1-T5.10 + T5.12) | merged into docs branch (`a7326978..30f2bd00`) | [pr5-business-workspace-id.md](./pr5-business-workspace-id.md) | | **PR6** | ✅ merged | 13 (T5.11 + T6.1-T6.15) | merged into docs branch (`361e653d..87ea715c`) | [pr6-routes-paths-workspace.md](./pr6-routes-paths-workspace.md) | | **PR7** | ✅ merged | 4 (T7.1-T7.3 + T7.5; T7.4 是反注入验证无代码改动) | merged into docs branch (`1a6ccc9a..d8b13afc`) | [pr7-ci-boundary-scan.md](./pr7-ci-boundary-scan.md) | -| **PR8** | 🟡 pending | 0 | — | — | +| **PR8** | ✅ merged | 5 (T8.1 + T8.2/T8.3 合并 + T8.4 + T8.5 + T8.6) | merged into docs branch (`1fb07e48..f803f393`) | [pr8-headless-api-schema.md](./pr8-headless-api-schema.md) | -**测试基线**:**PR7 末 3241 passed + 31 skipped**(PR6 末 3214 + 30;+27 passed / +1 skip,PR7 新增 10 个 boundary 测试 + 17 个之前 flake 这次稳过的环境差)。PR5 末 3150 + 30;PR4 末 3136 + 26;PR3 末 3134 + 25;PR2 末 3087。**18 个 caplog 排序 flake 持续存在**(17 个 pre-existing + 1 PR6 引入 `test_path_migration_pending_warning::test_warns`,PR7 未引入新 flake)→ isolate 跑全 PASS,与 stage 无关;集中清理仍推迟到 follow-up。 +**测试基线**:**PR8 末 3250 passed + 31 skipped**(PR7 末 3241 + 31;+9 passed,PR8 新增 3 + 3 + 2 + 1 = 9 个 schema 测试)。PR6 末 3214 + 30;PR5 末 3150 + 30;PR4 末 3136 + 26;PR3 末 3134 + 25;PR2 末 3087。**18 个 caplog 排序 flake 持续存在**(17 个 pre-existing + 1 PR6 引入,PR7/PR8 均未引入新 flake)→ isolate 跑全 PASS,与 stage 无关;集中清理仍推迟到 follow-up。 + +### Stage 0 整体测试增长 +PR1 起到 PR8 末,从既有 ~3087 增到 3250 passed(+163 测试,覆盖:PG fixture / sqlite→pg 默认切换 / workspaces + memberships / auth + JWT + register + workspace 自建 / alembic + backfill / 业务表 workspace_id 哨兵 + cross-workspace 404 e2e + Paths workspace + 文件迁移 / langgraph.checkpoint boundary 围栏 / service_accounts + api_keys + external_users schema)。plan 测试规模预估栏目原本估 ~70 新增,实际 ~163——PR4/PR5/PR6 都比预估多 2-3x,主要是 cross-workspace 隔离的 boundary e2e 比 plan 估的更稠密。 ## 用户必须跟进的事(live verification / 决策) @@ -50,6 +53,7 @@ PR1 + PR2 + PR3 + PR4 + PR5 + PR6 + **PR7** 已 merge。**PR7 (2026-05-14)** 落 | ~~PR5 T5.11~~ | ~~ORM model.py `nullable=False` 翻转~~ | **PR6 已落** (commit `87ea715c`) | — | | PR5 T5.12 真机 PG smoke | `alembic 0002 → backfill → 0003` 端到端 | agent 不能起 RDS 操作 | 用户跟进;命令清单见 [pr5-business-workspace-id.md "Live smoke 命令"](./pr5-business-workspace-id.md#live-smoke-命令用户跟进) | | PR6 T6.15 真机迁移 smoke | `make migrate-paths --dry-run` → 真迁移 → lifespan warning 消失 → 双账户互访 404 | agent 起不了 dev 服务 | 用户跟进;命令清单见 [pr6-routes-paths-workspace.md "Live smoke 命令"](./pr6-routes-paths-workspace.md#live-smoke-命令用户跟进) | +| PR8 RDS 三张表存在 | `psql "$DATABASE_URL" -c "\dt service_accounts api_keys external_users"` 看 3 行;`\d+ api_keys` 看 `idx_api_keys_active ... WHERE revoked_at IS NULL` | agent 没 RDS 凭证 | 用户跟进;命令清单见 [pr8-headless-api-schema.md "Live smoke 命令"](./pr8-headless-api-schema.md#live-smoke-命令用户跟进) | ## 即将遇到的开放问题(plan 末尾列的,下个 session 处理) @@ -61,7 +65,26 @@ PR1 + PR2 + PR3 + PR4 + PR5 + PR6 + **PR7** 已 merge。**PR7 (2026-05-14)** 落 ## 下一步建议 -**PR8(service_accounts / api_keys / external_users schema only)**——Stage 0 最后一项。仿 PR3 纯 schema 模式:3 张表 + ORM,**不接路径 / 不写仓储 / 不接 API key 认证**(全留 Stage 1)。依赖只到 PR3 的 workspaces 表,与 PR4-PR7 完全独立。 +**Stage 0 工程层面 8 个 PR 全部 merge,agent 这一侧的代码工作收尾。** 剩下都是**用户必须做的 live verification**: + +1. **PR8 RDS 表存在** — `psql "$DATABASE_URL" -c "\dt service_accounts api_keys external_users"` +2. **PR6 真机文件迁移** — 起 dev 服务、跑 `make migrate-paths --dry-run`、确认 lifespan warning 消失 +3. **PR5 RDS alembic 0002→backfill→0003** — 端到端验证业务表 workspace_id 列 +4. **PR1 testcontainers PG smoke** — docker daemon 起来后跑 `pytest -m postgres -v` +5. **远程 RDS 密码轮换**(之前在聊天里给过明文) +6. **Push docs branch** 跑 GitHub CI(已 push,监 [backend-postgres-tests workflow](../../../.github/workflows/backend-postgres-tests.yml) 在 PG matrix 全绿) + +工程门 [Stage 0 退出 Go/No-Go](../../superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md#stage-0-退出-gono-go来自-phased-rollout-by-scale) 已基本满足:8 PR 全合 / 新增 ~163 测试全过 / CI 绿(待 push 后确认)/ 7 项不可逆 LOCK 已 sign-off。**业务门**("第一个付费意向客户")等业务进展;生产稳定运行 ≥ 2 周也属业务时序。 + +Stage 1 可启动的方向(plan 没排,但已具备底座): +- **headless API 鉴权层**接 PR8 三张表(API key middleware / token 生成与 sha256 / `@require_permission` scope 升级 / Pattern A vs B 路由分流) +- **frontend workspace picker / switching UI** +- **platform admin 管理 workspace 的 CLI / UI**(plan self-review 标记的 gap) +- **17 个 pre-existing caplog flake 集中清理**(一直推迟) + +--- + +PR8 经验回顾:纯 schema PR,**Inline + 严格 TDD(红→绿)** 跑得很顺。6 个 task 单链条但每个 task 互相独立——SA / api_key / external_user 三张表之间只通过 FK 关联,没有跨 task signature 协调。每个表都按"先建模型 → 写 insert smoke 红→绿 → 加 cascade 测试 → 加 constraint 测试"四步走,3 张表 25 分钟内全落。T8.6 反向 metadata registration 测试是踩过坑后的肌肉记忆——历史上多次"模型类写了但 persistence/models/__init__.py 漏 import → create_all 不建表 → 上线 SELECT 时炸",T8.6 把这条 invariant 永久锁住。 PR7 经验回顾:纯静态测试 PR,Inline 模式继续合适——5 个 task 单链条强耦合(先确定 allowlist 内容才能写扫描器,扫描器函数得是导出才能 self-test)。复用 PR4 同款"红→绿"严格 TDD:故意建空 allowlist 跑红、再填→绿;T7.4 反注入实验是对静态扫描器的"集成 smoke",确认现实 backend 文件 + 真实 allowlist 过滤路径同时生效——这一步比 9 个 self-test 都更有说服力。 diff --git a/docs/multi-tenant-redesign/03-impl/pr8-headless-api-schema.md b/docs/multi-tenant-redesign/03-impl/pr8-headless-api-schema.md new file mode 100644 index 00000000..45da3e72 --- /dev/null +++ b/docs/multi-tenant-redesign/03-impl/pr8-headless-api-schema.md @@ -0,0 +1,96 @@ +# PR8 · `service_accounts` + `api_keys` + `external_users` schema only + +> 实现笔记。对应 [docs/superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md](../../superpowers/plans/2026-05-10-stage-0-multi-tenant-foundation.md) PR8(T8.1-T8.6)。 +> +> 状态:**已落地**,5 个 commits 提交到 `docs/multi-tenant-redesign`(PR8 起始 `1fb07e48..` 结束 `f803f393`)。 + +## 范围 + +为 Stage 1 headless API 鉴权层准备底座:3 张新表 + ORM。仿 PR3 模式——纯 schema、不接路由、不写仓储、不暴露 API。 + +1. **`service_accounts`** — 非人身份,属于唯一 workspace。3 状态字段:`role`(Stage 0 仅 `member`)/ `identity_mode`(`collapsed` / `external_passthrough` / `both`,决定是否记录终端用户身份)/ `status`(`active` / `suspended` / `deleted`)。`workspace_id` FK CASCADE,`created_by` FK 用户 **RESTRICT**(防止误删带 SA 的 user)。 +2. **`api_keys`** — service_account 的凭证。`key_prefix` String(16) **全局 unique**(撤销后亦不复用,避免审计混淆),`key_hash` String(128) 存 sha-256 hex(plaintext 仅创建时返)。`scopes` String(1024) 逗号分隔(不用 PG `text[]` 以保 SQLite dev 双驱动兼容;Stage 2 切纯 PG 可平滑迁)。`revoked_at`/`expires_at`/`last_used_at`/`rate_limit_rpm` 全 nullable。**双驱动部分索引** `idx_api_keys_active`(key_prefix)WHERE revoked_at IS NULL——同时声明 `sqlite_where` + `postgresql_where`,鉴权热路径 prefix lookup 加速。`service_account_id` FK CASCADE。 +3. **`external_users`** — passthrough 模式下的终端身份。每次调用带 `X-External-User-Id` header 时 upsert 一行(Stage 1 起)。**复合 UNIQUE** `(service_account_id, external_id)`——同 external_id 可在不同 SA 下复用,但单 SA 下唯一。`workspace_id` 冗余存储(可经 SA 间接得到,但直接存以加速 workspace-scoped 跨 SA 聚合)。`metadata_json` JSON nullable=False default {} 存 plan tier / region / 自定义 tag。两个 FK 均 CASCADE。 +4. **ORM 注册** — `deerflow/persistence/models/__init__.py` 加 3 行 import 让 `Base.metadata.create_all()` 在 `init_engine` 启动时自动建 3 张表。`test_pr8_metadata_registration.py` 反向验证:拉一个 fresh SQLite 引擎 inspect 表名集合,断言 3 张表都在。 + +**不在范围**(Stage 1): +- API key 鉴权中间件 / token 生成 / hash 验证 +- `@require_permission` scope 升级(接 `service_account`/`api_key` 主体) +- Pattern A/B endpoint 设计 +- `external_users` upsert 逻辑 +- 鉴权层的 rate limiting / scopes 校验 + +## Tasks 完成清单 + +| Task | Commit | 关键改动 | +|---|---|---| +| **T8.1** | `1fb07e48` | `service_account/{__init__, model}.py` + insert smoke + 注册进 persistence.models | +| **T8.2 + T8.3** | `bb728978` | `test_cascade_on_workspace_delete` + `test_restrict_on_created_by_user_delete` | +| **T8.4** | `52e9999a` | `api_key/{__init__, model}.py` + 3 测试(column UNIQUE + 双驱动 partial index DDL + CASCADE) | +| **T8.5** | `6f806ff4` | `external_user/{__init__, model}.py` + 2 测试(复合 UNIQUE + CASCADE) | +| **T8.6** | `f803f393` | `test_pr8_metadata_registration.py` 反向验证 `Base.metadata.create_all()` 真的建 3 张表 | + +## 验收 + +- [x] **3 + 3 + 2 + 1 = 9 个新单测全过**(T8.1/T8.2/T8.3 三个 service_account;T8.4 三个 api_key;T8.5 两个 external_user;T8.6 一个 metadata registration) +- [x] **3 张表 `Base.metadata.create_all()` 自动建**:T8.6 inspect 表名集合断言 `{service_accounts, api_keys, external_users}.issubset(tables)` +- [x] **FK 行为按 plan 设计**:CASCADE on workspace/SA delete、RESTRICT on creator user delete、SQLite + PG 均生效(SQLite 通过 engine.py connect-listener 的 `PRAGMA foreign_keys=ON`) +- [x] **partial index 双驱动 DDL** 通过 `dialect_options` 检查锁定(不只看 SQLAlchemy emit,下次有人删 `postgresql_where` 测试会红) + +## 文件结构 + +**新增**: +- `backend/packages/harness/deerflow/persistence/service_account/{__init__.py, model.py}` +- `backend/packages/harness/deerflow/persistence/api_key/{__init__.py, model.py}` +- `backend/packages/harness/deerflow/persistence/external_user/{__init__.py, model.py}` +- `backend/tests/test_service_account_schema.py`(3 cases) +- `backend/tests/test_api_key_schema.py`(3 cases) +- `backend/tests/test_external_user_schema.py`(2 cases) +- `backend/tests/test_pr8_metadata_registration.py`(1 case) +- `docs/multi-tenant-redesign/03-impl/pr8-headless-api-schema.md` — 本文件 + +**修改**: +- `backend/packages/harness/deerflow/persistence/models/__init__.py` — 加 3 行 import + `__all__` 注册 + +## 关键设计决策 + +1. **`key_prefix` 全局 UNIQUE,而非"活跃 UNIQUE"**:column-level `unique=True` 覆盖整个 key 生命周期。理由:撤销 + 复用同前缀会让审计日志里 "prefix X did Y" 的语义模糊;prefix 16 字符的命名空间足够大(≈10^25)从不复用没有成本。`idx_api_keys_active` 走部分非唯一索引——纯粹是热路径优化,撤销 key 不进活跃索引以减小热索引大小。 +2. **`scopes` 用 `String(1024)` 而非 PG `text[]`**:Stage 0 仍要 SQLite 跑得动(dev / unit test 兜底)。逗号分隔字符串两端通用;Stage 2 切纯 PG 后再迁 `text[]` + GIN 索引代价低。LOCK 由 plan 记下。 +3. **`external_users.workspace_id` 冗余存储**:技术上可从 `service_account_id` JOIN 出来,但 Stage 1 几个高频查询(workspace 级配额聚合 / admin UI 列出 workspace 所有 external user)每次走 JOIN 会随 SA 数量增长变慢。冗余一列、CASCADE 同 SA 一致,是值得的存储成本。 +4. **不写 Repository 类**:Stage 0 PR3 / PR5-6 的 Repository 是给 Gateway 当前在用的表准备的。PR8 三张表 Stage 0 内**没人读写**——直到 Stage 1 headless API 才用得上。写空 Repository 现在不知道接口形态,等 Stage 1 真用时连同 token 生成 / 哈希校验一起设计更合理。Plan 也明确"仅暴露 ORM"。 +5. **T8.6 反向 metadata registration 测试**:Stage 0 已经踩过坑——PR1-PR6 多次出现"模型类写了但 `persistence/models/__init__.py` 漏 import → `create_all()` 不建表 → 上线后 SELECT 时炸 'no such table'"。T8.6 把这条 invariant 锁定。 +6. **`identity_mode` 三态保持字符串而非 enum**:和 `role` / `status` 同款思路——String(16) 比 enum 更易加值(Stage 2 可能加 `cli_only` 等新态),不动 schema。 + +## Live smoke 命令(用户跟进) + +PR8 纯 schema 改造,无路由 / 中间件 / 文件系统副作用。 + +**单机自检**: +```bash +make stop && make dev # 起服务,让 lifespan 跑 init_engine +# 看 Gateway 启动日志无报错;create_all 默认 silent,无需额外断言 +``` + +**RDS 实跑表存在**(需要密码): +```bash +psql "$DATABASE_URL" -c "\dt service_accounts api_keys external_users" +# 期望:3 行 +psql "$DATABASE_URL" -c "\d+ api_keys" +# 期望看到 idx_api_keys_active (key_prefix) WHERE revoked_at IS NULL +``` + +**双驱动 partial index 在 PG 真生效**(可选): +```bash +# 用 testcontainers 跑 @pytest.mark.postgres 系列;当前 PR8 没写 PG 专属测试, +# 但 idx_api_keys_active 的 DDL 已在 dialect_options 里覆盖,PG schema dump +# 应见 "WHERE revoked_at IS NULL" +PYTHONPATH=. uv run pytest -m postgres -v +``` + +## Stage 0 退出门 + +PR8 是 Stage 0 工程层面最后一个 PR。剩余 Stage 0 退出条件见 [STATUS.md](./STATUS.md)"用户必须跟进的事": +- [ ] RDS 上 `service_accounts` / `api_keys` / `external_users` 三张表 `\dt` 见 +- [ ] `make migrate-paths --dry-run` 在 fresh DB 上输出空 +- [ ] testcontainers ephemeral PG smoke 跑过一次 +- [ ] 生产稳定运行 ≥ 2 周(业务条件)