本文是系列第 5 篇。上一篇 P3:工程化——异步入库、权限、审计与可观测 让这个知识库"能上线"了;再往前是 P2 质量、P1 MVP、P0 地基;整套路线见 总纲。
P3 收尾时我说过一句实话:那还是个单组织应用——所有人进同一个库、自己传自己发、账号密码本地一套。客户要接进来,第一句话通常是:"我们公司的人不能看到别家公司的文档吧?能走我们的企业微信/钉钉登录吗?发布前能加个审核吗?" 这一篇就是把这三个问题的答案一个个做出来。
阅读约定:
⚠️ 易错点都是真实踩过的坑,紧跟的✅ 解决方案可以直接照抄。代码已脱敏,但结构和参数与真实实现一致。
1. 这篇要做出什么
先把"单组织工程"和"企业级平台"的差距列清楚,P4 的任务清单就从这份差距里来:
| 单组织(P3 及之前) | 企业级(P4 目标) |
|---|---|
| 一个组织、一个库所有人共享 | 多租户:tenant_id 隔离,租户 A 的库对租户 B 完全不可见 |
| 传完即发布,人人可发 | 审批流:draft → ready → pending_review → published,提交人不能自审 |
| 本地账号密码一套 | 企业 SSO(OIDC/PKCE),可与本地账号并存 |
| token 存 localStorage(P3 债务) | httpOnly Cookie 会话 + refresh 轮换 + 即时吊销 |
| 文档进一个全局 collection | 向量 node 也打 tenant_id,检索双过滤 |
| 手动上传 | 本地文件夹连接器自动同步(增量 cursor) |
| 所有库同一套切分参数 | 每库 IngestProfile(parser/chunk/strategy) |
| 审计只有零散埋点 | append-only 审计 + 脱敏导出 |
做完后架构长这样(关键变化是"租户"成了一等公民,且 SSO/审批/连接器都挂在它下面):
┌──────────────────────────────────────────────┐│ Next.js 管理台 ││ 组织切换 / 审批队列 / 连接器配置 / ││ IngestProfile / 审计导出 │└───────────────────────┬──────────────────────┘│ Cookie(httpOnly) + credentials:include┌───────────────────────▼──────────────────────┐│ FastAPI (api) ││ require_active_tenant → TenantContext ││ ├─ 多租户:apply_tenant_filter 统一注入 ││ ├─ 审批:approval_service 状态机 + SoD ││ └─ SSO :OIDC/PKCE → claim 推导租户 (G3) │└───┬────────────────┬───────────────┬──────────┘│ │ │┌───────▼─────┐ ┌───────▼──────┐ ┌───────▼──────┐│ PostgreSQL │ │ Chroma │ │ ARQ Worker ││ 业务行 + │ │ node.tenant_ │ │ ingest / ││ tenant_id │ │ id 过滤(G1) │ │ connector ││ 应用层隔离 │ └──────────────┘ └──────┬───────┘└─────────────┘ │ 连接器┌─────────▼─────────┐│ 外部源(本地文件夹/ ││ 飞书/Notion...) │└───────────────────┘
一句话概括职责边界:tenant_id 是贯穿所有层的一等公民——PG 业务行有它、Chroma 向量 node 有它、JWT claim 里有它;任何"租户归属"的查询都必须经过 core/tenant.py 这唯一的注入点。
2. 四条设计红线(先立规矩再写代码)
P4 改动面大、容易顾此失彼。动手前先把四条不可妥协的红线写进计划,后面每个坑都绕不开它们:
| 代号 | 内容 | 主要落点 |
|---|---|---|
| G1 | 向量 node metadata tenant_id + 检索双过滤 |
tenant.py、retrieve.py、ingest.py |
| G2 | 应用层 tenant 注入为 P0(PostgreSQL RLS 只是 PG 环境增强) | tenant.py、deps.py |
| G3 | 租户由 IdP 断言推导、禁止 callback 自选 | oidc_service.py |
| G4 | 审批人 ≠ 提交人(dev 可例外) | approval_service.py |
⚠️ 路线级易错点 12:P4 一上来就想"一步到位"——把 RLS、WORM 审计哈希链、完整 Admin 控制台、飞书/Notion 连接器全排进同一周。
✅ 解决方案:按 P4a(企业治理:多租户+审批)→ P4b(SSO/会话)→ P4c(连接器/向量/审计) 三个切片推进,上一切片验收不过不开始下一切片。飞书/Notion、RLS、WORM 链统统标记为"P4+/P5 延后",不阻塞主验收。这一点救了整个阶段——每切片都有可演示、可测试的中间态。
3. 多租户隔离:从 User.tenant_id 到 TenantMembership
3.1 先拆清"三类租户身份"
最容易混淆的是"用户属于哪个租户"。P4 之前代码里只有一个 User.tenant_id,这是远远不够的。落地后变成三个独立概念:
| 概念 | 存哪 | 作用 |
|---|---|---|
| TenantMembership | tenant_memberships 表 |
用户是否属于某组织(一人可多组织) |
| active_tenant_id | JWT claim / 会话 | 当前请求"在哪个组织视角下操作" |
| User.tenant_id | users 表 |
仅首页偏好;claim 缺失时的 hint,不能单独当隔离依据 |
| KB Membership | memberships 表 |
用户在某个知识库里的角色(owner/reviewer/…) |
口述检查:TenantMembership 管"能不能进这个组织",KB Membership 管"在这个 KB 里能 upload/review 吗"。两者完全正交。
3.2 唯一注入点:core/tenant.py
⚠️ 易错点 1(最隐蔽的泄漏源):租户隔离写成"在 route 里随手
stmt.where(Model.tenant_id == current_user.tenant_id)"。功能当时能跑,但项目一大,总有某条查询忘记加这句——而忘记的那条,恰恰就是跨租户数据泄漏的口子。
✅ 解决方案:把隔离收敛成唯一出口core/tenant.py。所有 list/get 必须经apply_tenant_filter(stmt, Model.tenant_id, tenant_id)或专用 helper(get_kb_for_tenant/list_kbs_for_tenant)。code review 时只要看到"手散的tenant_id =="就打回。
# core/tenant.py —— 唯一注入点
def apply_tenant_filter(stmt, column, tenant_id) -> Select:return stmt.where(column == tenant_id)async def get_kb_for_tenant(db, kb_id, tenant_id) -> KnowledgeBase | None:stmt = apply_tenant_filter(select(KnowledgeBase).where(KnowledgeBase.id == kb_id),KnowledgeBase.tenant_id, tenant_id,)return await db.scalar(stmt) # 跨租户返回 None,不是抛错
active_tenant_id 的解析顺序(get_active_tenant_id)是 P4a 的心脏:
- 有 claim:用户确是该租户成员 → 用 claim;否则
TenantError("forbidden_tenant")→ 403 - 无 claim:若
User.tenant_id仍是成员 → 用它(home org 偏好) - 再 fallback:取第一条
TenantMembership(按创建时间) - 全无成员关系 →
no_tenant→ 400
⚠️ 易错点 2:只用
User.tenant_id当隔离依据 → 一人多组织时,切到组织 B 却仍在用 A 的隔离上下文,数据全串。
✅ 解决方案:隔离上下文一律来自会话active_tenant_id(claim),User.tenant_id仅作"没 claim 时的首页偏好"。所有人操作都走require_active_tenant依赖注入,路由里拿到的TenantContext.tenant_id才是可信的。
3.3 跨租户一律 404,不泄露存在性
⚠️ 易错点 3:Bob 直查 Alice 的 KB,返回 403 Forbidden——这等于告诉 Bob"有个 KB 存在于别的租户,只是你不许看",反而泄露了存在性。
✅ 解决方案:get_kb_for_tenant跨租户返回None,路由层统一映射为 404knowledge base not found。让越权者以为"这东西不存在",既安全又符合产品惯例。Chat 等读路径也纳入active_tenant:跨租户 chat → 404(非 403)。
验收点:
test_cross_tenant_list_empty_and_get_404、test_forged_active_tenant_claim_forbidden(伪造无 membership 的 claim → 403)、test_cross_tenant_chat_returns_404,均通过。
4. 审批流:状态机 + 职责分离
4.1 状态机(与 P3 的 ready/failed 合流)
P3 只有 draft → ready → published(一键 publish)。P4 在 ready 和 published 之间插入 pending_review,并新增 reviewer 角色:
draft --(worker OK)--> ready --(submit)--> pending_review --(approve)--> published| ^ |+--(worker fail)--> failed --(retry)--> draft || +--(reject)--> ready
published --(edit / unpublish)--> ready(须再 submit;禁止静默改线上答案)
submit前置:status == ready,需kb:uploadapprove/reject:需kb:review,且审批人 ≠ 提交人(生产默认)- 非法迁移(如
draft直接approve)→ 409 - 检索仍只返回
published(与 P3 一致)
4.2 审核不是用户标签,是资源权限 + 文档状态机
⚠️ 易错点 4(设计层面的大坑,总纲里就预警过):把"审核"做成
user.is_reviewer = true这种全局布尔字段。
✅ 解决方案:审核权限挂在知识库成员角色上,配合文档状态机。正确模型是User ─< Membership >─ KnowledgeBase,角色owner|manager|reviewer|viewer;Document.status走上面的状态机。"张三在 A 库是审核人、在 B 库只是普通成员"这种再正常不过的需求,用全局布尔根本表达不了。
角色 × 权限矩阵(锁定):
| 角色 | read | upload | review | publish* | delete | 连接器/Profile |
|---|---|---|---|---|---|---|
| owner | ✓ | ✓ | ✓(兜底) | ✓* | ✓ | ✓ |
| manager | ✓ | ✓ | ✗ | ✓* | ✓ | ✓ |
| reviewer | ✓ | ✗ | ✓ | ✗ | ✗ | ✗ |
| viewer | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
* require_approval=true 时:kb:publish 不可一键发布,发布仅经 approve。
⚠️ 易错点 5:
manager被顺手给了kb:review→ 上传者自己也能审,"审核"形同虚设。
✅ 解决方案:角色矩阵显式锁死——manager 无kb:review。审核权只给reviewer(和 owner 兜底)。
4.3 SoD:提交人不能自审(G4)
⚠️ 易错点 6:状态机有了,但"owner 上传 → owner 自己 approve"一路绿灯,审核等于没审。
✅ 解决方案(G4):_enforce_sod校验reviewer_id != submitter_id,否则抛self_review→ 403。APP_ENV=dev且ALLOW_OWNER_SELF_REVIEW=true且 actor 是 KB owner 时,才允许单人 demo 自审(默认关)。
def _enforce_sod(doc, user, kb):if not doc.submitter_id:raise ApprovalError("missing_submitter", "submitter_id required before review")if doc.submitter_id == user.id and not _self_review_allowed(user, kb):raise ApprovalError("self_review", "reviewer cannot be the submitter")
4.4 发布门禁:不让一键 publish 绕过审批
⚠️ 易错点 7:KB 开了
require_approval=true,但 P3 的一键publish接口忘了拦 → 上传者直接 publish,审批流程被架空。
✅ 解决方案:KB.require_approval新库默认 true;publish_service.set_published(publish=True)在require_approval is not False时抛approval_required→ 409。只有approve能把文档推到published。require_approval=false的旧库保留 P3 一键 publish(兼容)。
验收点:
test_http_submit_approve_queue_and_retrieve(owner 建库→上传→一键 publish 被 409→submit→owner 自审 403→reviewer approve→可检索)、test_publish_fails_when_kb_missing(KB 缺失 fail-closed)。
5. SSO / OIDC + httpOnly 会话(清偿 P3 债务)
5.1 租户由 IdP 推导,禁止自选(G3)
⚠️ 易错点 8(租户投毒):OIDC 回调里让前端传
tenant_id参数来决定"登录到哪个租户"——攻击者在 callback URL 里塞tenant_id=acme,直接进到别人的组织。
✅ 解决方案(G3):租户和角色完全由 IdP 断言推导——优先groups映射到OIDC_GROUP_TENANT_MAP,其次邮箱域名映射到OIDC_EMAIL_DOMAIN_TENANT_MAP;都匹配不到就拒绝登录。callback 里任何tenant_id参数一律忽略。
def map_claims_to_tenant(claims) -> tuple[str, str]:groups = claims.get("groups") or claims.get("realm_access", {}).get("roles") or []for g in groups:entry = settings.oidc_group_map().get(str(g))if entry and entry.get("tenant_slug"):return str(entry["tenant_slug"]), str(entry.get("role") or "member")# 再试邮箱域名 … 都失败 → 拒绝raise OidcError("unmapped", "no tenant mapping for IdP claims")
5.2 换掉 localStorage:httpOnly Cookie + refresh 轮换
⚠️ 易错点 9(P3 留下的债务):P3 把短 TTL access token 存进
localStorage,前端每个请求读取。问题有二:① XSS 一旦得手,token 直接被偷;② 吊销困难——token 在客户端,服务端没法让它"立刻失效"。
✅ 解决方案:access + refresh 都走 httpOnly + Secure + SameSite Cookie。前端不碰 token,只靠credentials: 'include'自动带 Cookie。access 短 TTL、不落 localStorage;refresh 在服务端RefreshSession表里可吊销。
前端关键约定(auth.ts / api.ts):
// 内存里只有 sessionKnown 布尔标志,不是 token
async function request(url: string) {const res = await fetch(url, { credentials: "include" });if (res.status === 401 && !url.includes("/auth/")) {await refreshSession(); // 401 单飞 refresh(refreshInFlight 防并发重复)return fetch(url, { credentials: "include" }); // 重试一次}return res;
}
⚠️ 易错点 10:401 并发时多个请求同时去 refresh → 刷新风暴、旧 refresh 互相吊销。
✅ 解决方案:refreshInFlight单飞——同一时刻只有一个 refresh 在飞,其余 401 复用其结果。
5.3 refresh 轮换 + 即时吊销
⚠️ 易错点 11:refresh token 不轮换、不吊销 → 一旦泄露,攻击者可长期冒充用户。
✅ 解决方案:rotate_refresh每次换新 pair 时把旧 jti 标记 revoked;revoke_all_for_user在登录前、切租户前、OIDC 前、用户被禁用/移出租户时调用,所有未吊销 refresh 一次性失效。
async def rotate_refresh(refresh_token):payload = decode_refresh(refresh_token)row = await db.get(RefreshSession, payload["jti"])if row is None or row.revoked or row.expires_at < now:raise AuthError("invalid_refresh") # 已吊销/过期 → 401row.revoked = True # 旧 jti 立即失效return await create_session(db, row.user_id, ...) # 发新 pair
⚠️ 易错点 12:OIDC-only 用户(无本地密码)却能用"本地密码登录"接口撞库。
✅ 解决方案:User.hashed_password可空;auth_provider='oidc'且密码为空时,本地密码登录路径直接拒绝。邮箱已存在 +email_verified=true时,OIDC 登录关联external_sub但保留本地hashed_password(不清成 SSO-only,避免锁死老账号);未验证邮箱不合并,防账号劫持。
验收点:
test_oidc.py全绿——test_claim_maps_tenant_no_self_select(G3)、test_refresh_rotation_and_revoke_on_disable(轮换+禁用吊销)、test_oidc_user_cannot_local_password_login、test_verified_email_links_without_clearing_password。
6. 向量隔离 + 连接器 + IngestProfile + 审计(P4c)
6.1 向量也要隔离(G1)
⚠️ 易错点 13:以为"PG 业务行加了
tenant_id就隔离了"——检索是直接打 Chroma 的,若某条入库路径漏给 node 打tenant_id,或reindex忘了 stamp,跨租户 chunk 就进了别人的检索结果。
✅ 解决方案(G1):入库给每个 Chroma/LlamaIndex node 打tenant_idmetadata;检索时MetadataFilters强制过滤 + 结果逐条硬过滤双保险。reindex/ingest_document/连接器 必须和 worker 上传走同一 stamp 路径,否则过滤后召回为空。
# 入库(ingest.py / ingest_document)
metadata["tenant_id"] = tenant_id # 每个 node 都打# 检索(retrieve.py)—— 双保险
retriever.retrieve(query) # 1. 向量层 MetadataFilters(EQ tenant_id)
filter_tenant_chunks(hits) # 2. 逐条 metadata_matches_tenant 硬过滤兜底
filter_published_chunks(hits, published_ids) # 3. PG 实时查 published 集合
RETRIEVE_OVERFETCH = 2:publish/tenant 过滤会丢 chunk,所以先多取 top_k*2 再滤,避免 top_k 被滤空。
6.2 本地文件夹连接器:只入队,不直写
⚠️ 易错点 14:连接器图省事直接写 Chroma → 绕过 worker 的状态机、进度、tenant stamp,等于在系统背后偷偷塞数据。
✅ 解决方案:连接器只把标准化文档enqueue_ingest,与上传完全同一条路径。删源文件走delete_document_artifacts(PG + Chroma + 上传文件三端一致)。
架构与同步逻辑:
ConnectorConfig (kb_id, type, config_json, sync_cursor, tenant_id)│├─ ARQ cron / POST 手动触发└─ connector_service.sync_local_folder├─ scan_folder(安全路径)├─ sha256 增量 diff vs sync_cursor├─ 新/变 → enqueue_ingest(不直写 Chroma)└─ 源删 → delete_document_artifacts(PG+Chroma+文件)
⚠️ 易错点 15(路径穿越/跨租户读文件):连接器配置
../outside、C:/secrets、symlink 逃逸、或tenant_b/...当 active 是 tenant_a → 读到别人磁盘上的文件。
✅ 解决方案:白名单根CONNECTOR_LOCAL_FOLDER_PATH;normalize_tenant_relative_path强制{tenant_id}/…前缀;resolve_safe_path拒..、绝对路径、盘符、symlink;sync_cursor用{relative_path: sha256}做增量,避免全量重跑风暴。
6.3 IngestProfile:每库配置,不是 P5 flow graph
每个 KB 存一份 JSON 配置(parser / chunk_size / chunk_overlap / default_strategy),被上传、连接器、reindex、chat(未显式传 strategy 时)共用。
⚠️ 易错点 16:把 IngestProfile 当成 P5 的"可视化 flow graph 编排"来做 → 范围爆炸。
✅ 解决方案:明确边界——IngestProfile 只是每库参数,不是节点图。改 profile 不会自动全量重跑(避免误删/风暴),只提供手动POST .../reingest入口;自动全量重 ingestion 留作 P4+。
6.4 审计:脱敏导出 + 删租户去标识
审计 append-only 写 AuditLog(actor/kb/action/detail/tenant),覆盖登录、上传、审批、连接器、SSO、配置变更。
⚠️ 易错点 17:导出 CSV 把用户邮箱等 PII 明文 dump 出去 → 合规事故。
✅ 解决方案:redact_pii把 email →[REDACTED_EMAIL];ACL 限制——非平台管理员只能导出自己 managed 的 KB 审计。删租户时purge_tenant_business_data清业务+向量,审计行去标识保留(actor_user_id=null+ 再脱敏),平衡"合规追溯"与"被遗忘权"。
⚠️ 易错点 18(刻意不做的事):想上"WORM 审计哈希链"(每条不可篡改、首尾哈希相连)。
✅ 解决方案:WORM 链与"被遗忘权/删租户"直接冲突——删租户要求抹掉个人标识,哈希链要求永久不可改。权衡后 P4c 不做完整哈希链,只做到去标识保留,把 WORM 链明确留给 P4+。能说清"为什么不做",比硬上更重要。
验收点:
test_vector_tenant_filter.py(跨租户召回为空)、test_connector_local.py(穿越/symlink/盘符/跨租户前缀全拒)、test_ingest_profile.py、test_audit_export.py(脱敏 + managed KB 过滤)、test_purge_clears_business_keeps_redacted_audit)均通过。
7. 验收 & 这一阶段的坑清单小结
P4 分三切片验收,按"上一切片不过不开始下一切片"推进:
- P4a 企业治理:租户 A 对 B 列表空 + 直查 404;状态机 + SoD + require_approval 门禁;★G4 自审 403。
- P4b SSO/会话:OIDC 租户由 claim 推导、本地账号仍可用;refresh 轮换 + 吊销;token 不落 localStorage。
- P4c 连接器/向量/审计:★G1 向量不返回他租户 chunk;本地文件夹同步 + 路径安全;IngestProfile 生效;审计导出脱敏。
测试结果:P4 相关测试 67 passed(tenant / vector / oidc / approval / connector / rbac / publish-gate);全量 uv run pytest -q 187 passed @ 2026-08-15。
刻意延后(计划中已占位,不阻塞):PostgreSQL RLS 必验、WORM 审计哈希链、完整 Admin UI、审批/连接器前端页、飞书/Notion 连接器。
P4 的四个核心教训,一句话记牢:
- 隔离要有唯一出口(G2)——别让
tenant_id散落在 50 个 route 里。 - 审核是状态机 + 资源权限,不是用户布尔字段(G4)——防自审靠 SoD。
- SSO 的租户必须 IdP 说了算(G3)——用户自选 = 投毒。
- 向量也要打 tenant_id(G1)——PG 隔离拦不住直打 Chroma 的检索。
到这篇结束,它已经是一个能进客户 IdP、多租户彼此隔离、发布前可审核、能接外部数据源的企业级知识库了。下一篇 [P5 产品化](待续) 会聊 Agent 多步检索、多模态、私有化与计费——那些"持续演进"的事。
系列文章会陆续更新,有问题欢迎评论区交流。