AI TRADE · DEVELOPMENT GUIDE

多租户隔离与权限设计

业务能力 · 权限模型 · 表结构 · 文件与会话 · 撤权边界

多租户隔离与细粒度权限架构设计

版本:v1.1 草案
日期:2026-09-30
状态:待评审,不代表已经实施

1. 设计目标

本方案为业务节点设计统一的租户隔离、成员权限、对象权限、文件授权、AI 会话和后台任务安全边界。目标是让普通页面、AI 工具、异步 Worker、文件接口和外部适配器使用同一套授权原则,避免每个模块单独实现一套权限判断。

系统必须满足以下性质:

  1. 不同租户之间的数据、文件、会话、任务和审计记录不可互相读取或修改。
  2. 同一个用户加入多个租户时,每次请求明确使用一个租户上下文;不能把用户的全局身份直接当作业务权限。
  3. 同租户成员可以拥有不同的读取、新增、修改、删除、导出、管理和授权能力。
  4. 对象可按企业共享、团队共享、指定成员或本人私有划分可见范围。
  5. 任何请求都在服务端重新验证租户、成员状态、角色、模块权限和对象权限;前端参数、对象 ID、文件路径、模型输出和任务载荷都不构成授权凭据。
  6. 成员撤销、租户归区变化、角色变化和对象授权变化能够阻止后续请求、长连接、附件访问和后台任务继续越权执行。
  7. 数据库、文件存储和外部知识系统分别有自己的授权防线,不能只依赖 Java API 的一层判断。

本方案沿用当前职责边界:中央维护用户、租户、有效成员、租户角色和企业到节点的分配;业务节点维护业务对象、模块授权、对象授权、任务和审计;WeKnora 通过正式 API 接入,不直接写其内部数据库。

2. 推荐授权模型

采用四层模型组合:

不把单一角色当成全部授权。角色解决“这个成员通常能做什么”,对象关系解决“这个成员能操作哪些对象”,属性条件解决“当前状态下是否允许执行”。

2.1 权限动作

权限动作使用稳定的机器标识:

动作 含义
read 查看对象内容和列表摘要
create 创建对象
update 修改对象
delete 删除或归档对象
export 导出对象或批量数据
manage 管理模块配置、知识库和任务策略
share 授予或撤销对象访问关系
execute 执行外部副作用或启动任务
admin 管理租户模块、成员授权和安全策略

动作必须与模块和资源类型组合,例如:

crm.customer.read
crm.customer.update
knowledge.document.manage
mail.message.execute
chat.conversation.read
file.object.read
job.export.execute
member.permission.manage

权限字符串是应用契约的一部分,数据库中用唯一约束防止重复。删除权限定义应采用停用或版本化,不能直接删除仍被授权记录引用的权限。

2.2 默认角色

默认角色只提供基础权限,最终是否允许还要经过对象范围和状态条件。

角色 默认能力
tenant_admin 租户内全部模块管理、成员授权、审计读取;不能绕过中央成员有效性
manager 指定模块的读写、团队范围管理和有限分享;不能管理中央成员
sales 获客、CRM、邮件草稿和指定 AI 工具的业务操作;范围由团队或对象关系限定
knowledge_manager 企业知识库文档上传、更新、删除、索引重试和知识库配置;默认不能查看他人私人会话
viewer 受授权范围内读取;默认无新增、修改、删除、导出和副作用执行
service_worker 只作为受限机器执行身份,不代表某个用户;只能执行任务声明允许的动作,并在领取和提交时复核

中央返回的租户角色是成员资格和基础角色来源。节点可以维护模块级授权和对象级关系,但不能创建第二套成员目录,也不能让节点本地授权覆盖中央已撤销的成员资格。

3. 请求授权流程

所有业务请求统一经过以下顺序:

请求进入
  ↓
解析目标租户与资源标识
  ↓
验证节点会话或机器身份
  ↓
向中央复核 user_id、tenant_id、node_id、allocation_version、成员状态和租户角色
  ↓
加载节点模块权限和对象关系
  ↓
建立事务级 user/tenant/role/module_context
  ↓
PostgreSQL FORCE RLS 过滤租户边界
  ↓
应用层执行动作和对象状态检查
  ↓
记录审计与关联 trace/request/run 标识
  ↓
返回结果

任何一步失败都停止后续访问:

3.1 租户上下文

租户上下文只由可信认证链构造,至少包含:

user_id
 tenant_id
 central_role
 allocation_version
 node_id
 module_grants
 actor_type: browser | central_machine | worker
 request_id
 session_id / grant_id

浏览器传来的 tenant_id 只用于选择会话和路由,不能直接写入 SQL 上下文。节点必须比较路径租户、Cookie 名称、会话元数据、中央检查结果和业务对象租户,任一不一致都拒绝。

每个数据库事务使用 SET LOCAL 或 set_config(..., true) 设置:

SET LOCAL app.user_id = '...';
SET LOCAL app.tenant_id = '...';
SET LOCAL app.role = '...';
SET LOCAL app.member_id = '...';
SET LOCAL app.allocation_version = '...';
SET LOCAL app.request_id = '...';

连接归还连接池前上下文必须自动失效。禁止使用跨事务会话变量保存租户身份。

4. 数据库架构

4.1 数据库角色

至少分离以下数据库角色:

角色 用途 权限边界
node_owner 迁移和表所有者 不作为应用连接账号
node_migrator 执行迁移 仅迁移窗口使用,凭据受限
node_app API 运行 无表所有权、无 BYPASSRLS
node_worker Java Worker 只执行领取、心跳和必要业务函数
node_readonly 可选报表只读 仍受 RLS,不可绕过租户策略

运行角色不能是 PostgreSQL 超级用户、表所有者或具有 BYPASSRLS 的角色。SECURITY DEFINER 函数必须固定 search_path,明确参数和调用者身份,不得通过动态 SQL 拼接租户值。

4.2 核心身份与授权表

表名可在实施阶段调整,但语义保持一致。

tenant_module

租户开通的模块。

id uuid primary key
tenant_id uuid not null
module_key text not null
status text not null              -- active/suspended
config jsonb not null default '{}'
created_at timestamptz not null
updated_at timestamptz not null
unique (tenant_id, module_key)

permission_definition

系统级权限字典。

permission_key text primary key
module_key text not null
action text not null
resource_type text not null
status text not null               -- active/deprecated
version integer not null
description text not null
unique (module_key, action, resource_type)

role_definition

节点默认角色定义。中央角色仍由中央决定,节点不复制成员目录。

role_key text primary key
scope text not null                 -- system/tenant/module
label text not null
managed boolean not null default true

role_permission

角色到权限的映射。

role_key text not null references role_definition(role_key)
permission_key text not null references permission_definition(permission_key)
constraints jsonb not null default '{}'
primary key (role_key, permission_key)

tenant_member_grant

节点侧模块授权快照或授权补充。它不能把无效中央成员变成有效成员。

id uuid primary key
tenant_id uuid not null
user_id uuid not null
role_key text not null
status text not null                 -- active/revoked/expired
source text not null                  -- tenant_admin/system
starts_at timestamptz not null
grant_expires_at timestamptz
version bigint not null default 1
created_by uuid
updated_by uuid
created_at timestamptz not null
updated_at timestamptz not null
unique (tenant_id, user_id, role_key)

如果中央后续直接提供模块权限快照,应保存 central_grant_version 和来源标识,保留审计,但仍以中央成员状态为最终门禁。

member_team 与 team_member

用于数据范围和团队协作。

member_team(
  tenant_id uuid not null,
  team_id uuid not null,
  name text not null,
  status text not null,
  unique (tenant_id, team_id),
  unique (tenant_id, name)
)

team_member(
  tenant_id uuid not null,
  team_id uuid not null,
  user_id uuid not null,
  team_role text not null,             -- owner/manager/member
  status text not null,
  primary key (tenant_id, team_id, user_id)
)

4.3 业务表通用字段

所有企业业务表必须包含:

id uuid primary key
tenant_id uuid not null
created_by uuid not null
updated_by uuid
created_at timestamptz not null
updated_at timestamptz not null
row_version bigint not null default 1
visibility_scope text not null default 'tenant' -- tenant/team/users/private
status text not null

约束原则:

示例 CRM 客户表:

crm_customer(
  tenant_id uuid not null,
  id uuid not null,
  name text not null,
  owner_user_id uuid,
  visibility_scope text not null default 'tenant',
  created_by uuid not null,
  updated_by uuid,
  row_version bigint not null default 1,
  ...,
  primary key (tenant_id, id)
)

4.4 对象授权表

当角色和团队范围不足以表达具体对象授权时,使用 ACL。为避免 principal_id 同时承载 UUID 和文本,采用类型化列并加约束;下面是可执行 SQL 风格的核心定义:

CREATE TABLE object_acl (
  acl_id uuid PRIMARY KEY,
  tenant_id uuid NOT NULL,
  resource_type text NOT NULL,
  resource_id uuid NOT NULL,
  principal_type text NOT NULL CHECK (principal_type IN ('user', 'team', 'role')),
  principal_user_id uuid,
  principal_team_id uuid,
  principal_role_key text,
  permission_key text NOT NULL,
  status text NOT NULL DEFAULT 'active'
    CHECK (status IN ('active', 'revoked', 'expired')),
  expires_at timestamptz,
  granted_by uuid NOT NULL,
  created_at timestamptz NOT NULL,
  revoked_at timestamptz,
  CHECK ((principal_type = 'user' AND principal_user_id IS NOT NULL
          AND principal_team_id IS NULL AND principal_role_key IS NULL)
      OR (principal_type = 'team' AND principal_user_id IS NULL
          AND principal_team_id IS NOT NULL AND principal_role_key IS NULL)
      OR (principal_type = 'role' AND principal_user_id IS NULL
          AND principal_team_id IS NULL AND principal_role_key IS NOT NULL))
);

CREATE UNIQUE INDEX object_acl_user_uq ON object_acl
  (tenant_id, resource_type, resource_id, principal_user_id, permission_key)
  WHERE principal_type = 'user' AND status = 'active';
CREATE UNIQUE INDEX object_acl_team_uq ON object_acl
  (tenant_id, resource_type, resource_id, principal_team_id, permission_key)
  WHERE principal_type = 'team' AND status = 'active';
CREATE UNIQUE INDEX object_acl_role_uq ON object_acl
  (tenant_id, resource_type, resource_id, principal_role_key, permission_key)
  WHERE principal_type = 'role' AND status = 'active';

所有主体和资源关联都必须带 tenant_id 的复合外键或由受保护函数校验;对象 ID 相同但租户不同的记录不能互相命中。v1 采用“默认拒绝、显式允许、无独立 deny 规则”的确定性策略:撤销通过 status 和 revoked_at 生效,过期通过 expires_at 生效。若未来需要 deny 覆盖规则,必须另行定义优先级、冲突测试和审计语义。

推荐授权决策顺序:

  1. 中央成员有效性和租户归属。
  2. 租户模块是否启用。
  3. 用户角色和模块权限。
  4. 团队、负责人或对象 ACL。
  5. 对象状态、动作条件和确认要求。
  6. RLS 最终过滤。

对象 ACL 撤销、成员变化和团队变化必须使相关缓存失效;权限缓存不能作为最终授权事实。

5. PostgreSQL RLS 设计

每个业务表都启用并强制执行 RLS:

ALTER TABLE crm_customer ENABLE ROW LEVEL SECURITY;
ALTER TABLE crm_customer FORCE ROW LEVEL SECURITY;

统一策略函数示例:

CREATE FUNCTION private.current_tenant() RETURNS uuid
LANGUAGE sql STABLE AS $$
  SELECT nullif(current_setting('app.tenant_id', true), '')::uuid
$$;

CREATE FUNCTION private.current_user() RETURNS uuid
LANGUAGE sql STABLE AS $$
  SELECT nullif(current_setting('app.user_id', true), '')::uuid
$$;

CREATE FUNCTION private.tenant_allowed(uuid) RETURNS boolean
LANGUAGE sql STABLE AS $$
  SELECT $1 = private.current_tenant()
$$;

RLS 的职责是保证租户边界和基础行范围;复杂的对象授权仍由安全的授权函数或应用服务先判定。不要把所有 ACL 逻辑复制到几十个 SQL policy 中造成不可维护的授权分叉。

推荐两层策略:

重要的写入应使用数据库函数或带租户条件的 SQL:

UPDATE crm_customer
SET name = ?, row_version = row_version + 1
WHERE tenant_id = current_setting('app.tenant_id')::uuid
  AND id = ?
  AND row_version = ?;

更新影响行数为零时返回对象不存在、无权或版本冲突,不能直接返回成功。

6. 文件隔离设计

文件系统路径不是授权机制。文件必须有数据库元数据,文件内容存私有对象存储或受限文件服务。

6.1 文件元数据

file_object(
  tenant_id uuid not null,
  id uuid not null,
  storage_key text not null,
  original_name text not null,
  detected_media_type text not null,
  size_bytes bigint not null,
  checksum_sha256 text not null,
  owner_user_id uuid not null,
  resource_type text not null,       -- conversation/document/customer
  resource_id uuid not null,
  visibility_scope text not null,
  scan_status text not null,         -- pending/clean/rejected/failed
  created_by uuid not null,
  created_at timestamptz not null,
  deleted_at timestamptz,
  unique (tenant_id, storage_key),
  primary key (tenant_id, id)
)

文件授权关系复用 object_acl:使用 resource_type = 'file'、resource_id = file_object.id,主体列采用上一节的类型化定义。文件的基础可见性来自关联会话、文档或业务对象;只有需要额外指定成员时才增加文件级 ACL,避免出现一套与对象权限不一致的 file_acl 规则。

6.2 文件访问流程

上传:

  1. 验证当前租户和成员的 file.create 或业务模块上传权限。
  2. 验证目标会话、知识库或业务对象属于当前租户且当前用户可写。
  3. 限制大小、扩展名、真实 MIME、内容签名、压缩包展开比例和文件名。
  4. 写入隔离的临时对象并扫描;扫描通过后写正式对象存储。
  5. 先写元数据和审计,再返回不含真实存储路径的文件 ID。

下载/预览:

  1. 从文件 ID 找到元数据,查询本身受租户 RLS 限制。
  2. 校验当前用户对关联会话、文档或业务对象有 file.read。
  3. 校验 scan_status=clean 且未删除。
  4. 默认由带认证的文件代理流式返回,以便撤权立即生效。
  5. 如使用对象存储临时 URL,必须视为可转发的 bearer 凭据,设置短过期时间、单对象范围和审计;撤权不能保证已签发 URL 立即失效,因此高敏感文件禁止仅依赖临时 URL。
  6. 不要把可猜测路径直接交给浏览器。

AI 引用文件时,模型只能获得当前请求授权的文件内容或受控引用。模型生成的文件 ID、路径和 URL 仍必须重新通过授权层校验。

7. 会话与消息权限

会话元数据可以由节点保存,但 WeKnora 是 AI 会话消息、消息历史和消息内容的唯一持久化事实来源。Java 不在自己的 PostgreSQL 中复制完整 chat_message 历史,也不直接写 WeKnora 内部数据库;通过 WeKnora 正式 HTTP API 读写,并在节点保存授权所需的映射和审计元数据。浏览器提交的历史消息不能被当作正式记录。

chat_conversation_binding

节点侧只保存会话授权和外部 ID 映射:

chat_conversation_binding(
  tenant_id uuid not null,
  id uuid not null,
  owner_user_id uuid not null,
  weknora_conversation_id text not null,
  title text,
  visibility_scope text not null default 'private', -- private/team/tenant
  status text not null default 'active',
  created_at timestamptz not null,
  updated_at timestamptz not null,
  row_version bigint not null default 1,
  primary key (tenant_id, id),
  unique (tenant_id, weknora_conversation_id)
)

节点保存的 owner_user_id、可选参与者/ACL、租户和 WeKnora 会话 ID 必须在同一租户内关联。WeKnora 的 API 身份、会话 ID 与节点绑定表不能单独构成用户授权;每次读历史、发消息、恢复流、停止生成和读取附件都要先通过节点授权,再调用 WeKnora API。WeKnora 原生管理界面产生的会话也必须经过映射和授权桥接,不能因内部管理员身份自动成为业务租户成员。

权限规则:

节点当前附件表中的会话和用户字段属于过渡性的上传权限与映射数据;迁移到统一文件模型时,文件必须继承绑定会话的有效读取权限,并在下载时重新校验。

8. 后台任务与外部副作用

任务表必须包含:

tenant_id
requested_by
requested_permission_snapshot
resource_type
resource_id
idempotency_key
status
claim_token
lease_expires_at
attempt_count
last_error
created_at

Worker 流程:

  1. API 创建任务前验证发起成员、租户模块和对象权限。
  2. 任务固化租户、用户、对象和授权版本快照。
  3. Worker 领取时获得租约,但不把旧快照当作当前授权。
  4. 执行前重新向中央检查成员和归区,并在本地检查模块/对象权限。
  5. 外部副作用前检查幂等键和确认状态。
  6. 提交结果前再次检查租约、对象版本和撤权状态。
  7. 任务结果按业务对象权限返回;不能按“谁创建谁永远可读”简单处理。

邮件发送、删除、批量修改等动作需要明确确认;模型只能提出工具调用,不能替用户获得 execute 权限。

9. 权限缓存与一致性

初期不要让 Redis 成为唯一授权事实。可缓存:

缓存键至少包含:

tenant_id:user_id:central_grant_version:module:resource_type:resource_id:action

以下事件必须使相关缓存失效:

短缓存不能替代关键操作前的实时复核。长流每隔固定时间复核,后台任务在领取、执行和提交阶段复核。

10. 大厂成熟方案的取舍

成熟系统通常不是只选 RBAC,而是组合:

Google Zanzibar、OpenFGA、SpiceDB 的共同思想是:把“用户与对象之间的关系”作为可查询的授权图,并让每个服务通过统一 Check API 判断动作。当前节点仍是模块化 Spring Boot、业务模块数量有限,建议先在 PostgreSQL + Java 中实现统一 AuthorizationService 和标准关系表;当出现多节点、多服务、多种资源和高频授权查询后,再评估 OpenFGA/SpiceDB。

不建议当前直接引入独立授权服务,原因是会增加双写、一致性、故障降级和中央成员同步复杂度。无论是否引入授权服务,数据库 RLS 和文件元数据授权都必须保留。

11. 推荐 Java 模块边界

TenantContextResolver
  - 解析可信会话、中央授权和租户上下文

CentralMembershipClient
  - 查询中央成员、角色、归区版本

ModuleGrantService
  - 查询租户模块与成员模块权限

AuthorizationService
  - check(user, tenant, action, resource)
  - require(...)
  - 统一返回允许、拒绝和隐藏对象语义

ObjectScopeService
  - team/owner/ACL/visibility范围判断

FileAuthorizationService
  - 文件关联对象、扫描状态和临时访问

AuditService
  - 记录授权变化、敏感读取和副作用

TenantTransaction
  - 同一事务设置LOCAL上下文并清理连接

控制器不能直接拼角色判断和 SQL 条件。控制器只负责参数绑定和调用应用服务;应用服务执行授权;Repository 使用已经绑定的事务上下文;数据库 RLS 提供最终租户边界。

12. 实施顺序

阶段一:身份和基础授权

阶段二:模块权限和对象范围

阶段三:文件与会话

阶段四:任务与外部副作用

阶段五:容量与授权服务评估

13. 必须通过的验收矩阵

场景 预期
用户 A 访问租户 B 的 URL 401/403,不返回数据
用户 A 知道租户 B 的对象 ID 404 或 403,不泄露存在性
同一用户切换两个租户 会话、数据、文件和缓存完全切换
同租户 viewer 写入 CRM 403
同租户 sales 读取授权范围外客户 404/403
同租户知识管理员查看他人私人会话 默认拒绝
用户读取另一成员附件 默认拒绝
用户用另一租户会话 ID读取附件 拒绝
成员被中央撤销后读取页面 403
撤权中的 AI 长流 周期复核后取消
撤权中的 Worker 执行前或提交前拒绝
旧租约提交新租约结果 拒绝,不覆盖新结果
重复上传或重复发送 幂等或明确冲突,不产生重复副作用
直接访问对象存储路径 无权限或短时授权失效
跨租户数据库 JOIN RLS 拒绝或返回空集
连接池复用事务 不残留前一个租户上下文

14. 当前项目的落地结论

当前节点已经具备中央会话、租户上下文、基础角色和现有业务表 FORCE RLS,但还没有完整的模块授权、对象 ACL、持久聊天历史和统一文件授权体系。现有附件功能可以作为文件授权的第一步,但应继续迁移到本方案的 file_object 与持久会话模型。

下一步实施前必须先确定:

  1. 成员模块权限是否由中央下发,还是由节点租户管理员在节点维护。
  2. 租户管理员是否可以查看成员私人会话;本方案默认不能。
  3. CRM 客户默认采用全企业共享;团队和负责人限制作为按模块/对象启用的扩展规则。
  4. 文件是否统一进入公共对象存储,还是先使用节点私有存储。
  5. 是否需要团队层级和跨团队分享。
  6. 首阶段只在 Java + PostgreSQL 实现授权,达到多服务和高频授权规模后再评估 OpenFGA/SpiceDB。

本文件只提供设计基线。完成评审后,再按确定的字段和迁移顺序实现数据库、授权服务、接口和测试;不得直接把本方案中的逻辑表名当作现有生产表。

2026-10-08 删除任务的实施补充(已发布,验收见 knowledge-delete-release-20261008.md)

知识资料删除实现遵守本设计的任务授权、RLS、对象范围与副作用幂等边界。节点使用 knowledge_deletions 持久任务、互斥租约和 CAS 提交;上游独立 API 冻结文件资源清单并核实清理完成,节点不直接修改 WeKnora 表。未知存储引用、权限撤销、归区或知识库映射变化、缺失完成证据均保持待处理。原请求成员失权不会转换为管理员读取其私人资料的权限。独立测试覆盖租户/本人边界、重复受理、旧租约拒绝、原文件删除后响应丢失、共享引用保留、解析写入围栏。完整契约和剩余限制见 可靠删除协议。

2026-10-09 双范围检索与分享补充(候选,未发布)

本人会话的可读集合为当前企业中本人个人资料与企业共享资料,物理个人库共用不等于成员共享;管理员不获得他人私人内容。节点生成逐文档签名范围,上游在检索和工具读取前约束范围,空集合不能回退整库。历史上下文按范围与文档版本指纹选择,无来源证明的摘要和长期记忆不自动注入。真实API隔离验收与生产启用分开记录,见范围协议。

分享只针对所有者明确提交的快照,经审批创建独立企业副本。当前管理员可显式接管已批准任务,不能借接管读取所有者其他私人资料。上传、接管、取消和失败重解析通过独立WeKnora持久操作及同一文档锁协作;取消记录在文件不存在时也保留,禁止迟到上传复活。节点依赖有效租约固定物理库并CAS提交,正常解析轮询不消耗错误预算。长期状态未变化时暂停核对,不盲目发起重复解析。原件、副本分别删除且各自清理文件、结构化数据和向量。完整待办及测试边界见验收清单。

2026-10-09 残留自动清理实施补充

节点迁移027以tenant_id和复合外键隔离扫描/对象任务,FORCE RLS;页面触发校验当前企业管理员或本人身份,Worker每次领取和提交重新向中央复核。个人物理库可能共享,自动清理只接收本人已完成删除记录中的文档白名单;企业扫描须节点管理员用途签名。上游扫描/清理只处理已软删除对象,清理互斥锁内再验证,文件资源共享引用保护,使用幂等任务和退避。详情见knowledge-residual-cleanup-v1.md;未知归属不自动清理。