前端转 AI 第 05 篇|P4:企业能力——多租户、SSO、审批流、连接器

前端转 AI  第 05 篇|P4:企业能力——多租户、SSO、审批流、连接器

本文是系列第 5 篇。上一篇 P3:工程化——异步入库、权限、审计与可观测 让这个知识库"能上线"了;再往前是 P2 质量P1 MVPP0 地基;整套路线见 总纲
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.pyretrieve.pyingest.py
G2 应用层 tenant 注入为 P0(PostgreSQL RLS 只是 PG 环境增强) tenant.pydeps.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_idTenantMembership

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 的心脏:

  1. 有 claim:用户确是该租户成员 → 用 claim;否则 TenantError("forbidden_tenant")403
  2. 无 claim:若 User.tenant_id 仍是成员 → 用它(home org 偏好)
  3. 再 fallback:取第一条 TenantMembership(按创建时间)
  4. 全无成员关系 → no_tenant400

⚠️ 易错点 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,路由层统一映射为 404 knowledge base not found。让越权者以为"这东西不存在",既安全又符合产品惯例。Chat 等读路径也纳入 active_tenant:跨租户 chat → 404(非 403)。

验收点:test_cross_tenant_list_empty_and_get_404test_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 在 readypublished 之间插入 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:upload
  • approve/reject:需 kb:review,且审批人 ≠ 提交人(生产默认)
  • 非法迁移(如 draft 直接 approve)→ 409
  • 检索仍只返回 published(与 P3 一致)

4.2 审核不是用户标签,是资源权限 + 文档状态机

⚠️ 易错点 4(设计层面的大坑,总纲里就预警过):把"审核"做成 user.is_reviewer = true 这种全局布尔字段。
解决方案:审核权限挂在知识库成员角色上,配合文档状态机。正确模型是 User ─< Membership >─ KnowledgeBase,角色 owner|manager|reviewer|viewerDocument.status 走上面的状态机。"张三在 A 库是审核人、在 B 库只是普通成员"这种再正常不过的需求,用全局布尔根本表达不了。

角色 × 权限矩阵(锁定):

角色 read upload review publish* delete 连接器/Profile
owner ✓(兜底) ✓*
manager ✓*
reviewer
viewer

* require_approval=true 时:kb:publish 不可一键发布,发布仅经 approve

⚠️ 易错点 5manager 被顺手给了 kb:review → 上传者自己也能审,"审核"形同虚设。
解决方案:角色矩阵显式锁死——manager 无 kb:review。审核权只给 reviewer(和 owner 兜底)。

4.3 SoD:提交人不能自审(G4)

⚠️ 易错点 6:状态机有了,但"owner 上传 → owner 自己 approve"一路绿灯,审核等于没审。
解决方案(G4)_enforce_sod 校验 reviewer_id != submitter_id,否则抛 self_review403APP_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 新库默认 truepublish_service.set_published(publish=True)require_approval is not False 时抛 approval_required → 409。只有 approve 能把文档推到 publishedrequire_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")

⚠️ 易错点 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 标记 revokedrevoke_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_logintest_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_id metadata;检索时 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(路径穿越/跨租户读文件):连接器配置 ../outsideC:/secrets、symlink 逃逸、或 tenant_b/... 当 active 是 tenant_a → 读到别人磁盘上的文件。
解决方案:白名单根 CONNECTOR_LOCAL_FOLDER_PATHnormalize_tenant_relative_path 强制 {tenant_id}/… 前缀;resolve_safe_path..、绝对路径、盘符、symlinksync_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.pytest_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 的四个核心教训,一句话记牢:

  1. 隔离要有唯一出口(G2)——别让 tenant_id 散落在 50 个 route 里。
  2. 审核是状态机 + 资源权限,不是用户布尔字段(G4)——防自审靠 SoD。
  3. SSO 的租户必须 IdP 说了算(G3)——用户自选 = 投毒。
  4. 向量也要打 tenant_id(G1)——PG 隔离拦不住直打 Chroma 的检索。

到这篇结束,它已经是一个能进客户 IdP、多租户彼此隔离、发布前可审核、能接外部数据源的企业级知识库了。下一篇 [P5 产品化](待续) 会聊 Agent 多步检索、多模态、私有化与计费——那些"持续演进"的事。

系列文章会陆续更新,有问题欢迎评论区交流。