系列「企业级 AI Agent 实现拆解」第十一篇。上一篇讲了长期记忆,这篇看多租户数据隔离怎么做。


为什么不用应用层过滤

最直觉的多租户隔离方案是:每个查询手动加 WHERE tenant_id = ?。问题在于,这个"每个"很容易出漏网之鱼。

一个几十张表、几十个 repo 的系统,某个开发者新写了一个查询,忘了加 tenant_id 过滤,review 也没发现——数据就串了。一个 bug 就能让 A 租户的数据暴露给 B 租户,这在企业 SaaS 里是 P0 安全事故。

我们用 PostgreSQL 的行级安全(Row-Level Security,RLS)做强制隔离:在数据库层定义策略,无论上层代码怎么写,数据库执行查询前都会自动加过滤条件。代码忘了加 WHERE,数据库帮你加。

你可以把 RLS 想象成一栋写字楼的门禁卡:每个公司(租户)的员工只能刷开自己楼层的门。不管谁写代码、从哪个入口进来,门禁系统都在起作用——不是靠每个员工"记得只去自己楼层"。


RLS 的工作原理

DeepFlux 在 deploy/sql/001_schema.sql 里,用一段 PL/pgSQL 循环对 12 张表批量启用 RLS:

-- deploy/sql/001_schema.sql
DO $$
DECLARE t text;
  tables text[] := ARRAY[
    'users','sessions','messages','memories','memory_profiles',
    'tool_audit','kb_namespaces','kb_documents','platform_connectors',
    'agent_configs','data_jobs','api_keys'
  ];
BEGIN
  FOREACH t IN ARRAY tables LOOP
    -- 打开行级安全
    EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
    -- 统一策略:只能看自己租户的行
    EXECUTE format($f$
      CREATE POLICY tenant_isolation ON %I
        USING  (tenant_id::text = current_setting('app.tenant_id', true))
        WITH CHECK (tenant_id::text = current_setting('app.tenant_id', true))
    $f$, t);
  END LOOP;
END $$;

两个关键点:

  • USING 子句:过滤 SELECT / UPDATE / DELETE,已有行只返回匹配的
  • WITH CHECK 子句:拦截 INSERT / UPDATE,新写入的行必须满足条件

比手动逐张表写策略更可靠——循环确保 12 张表一个不漏

每次数据库连接建立时,服务把当前租户 ID 注入进去:

SET LOCAL app.tenant_id = 'tenant-abc-123';

之后这个连接上的所有查询,12 张表都会自动加 AND tenant_id = 'tenant-abc-123'。应用层不用写任何过滤,数据库保证隔离。


向量也在 PostgreSQL 里:pgvector

一个常见的疑问是:向量数据存在专门的向量数据库(如 Qdrant)里,RLS 还能管得住吗?

DeepFlux 的做法是把向量也存进 PostgreSQL——通过 pgvector 扩展,在 kb_chunks 表上加了 embedding vector(1536) 列。知识库片段的文本和向量在同一张表、同一行里,自然受 RLS 保护:

-- deploy/sql/013_pgvector.sql
ALTER TABLE kb_chunks ADD COLUMN embedding vector(1536);
CREATE INDEX ON kb_chunks USING hnsw (embedding vector_cosine_ops);

检索时直接在 PostgreSQL 内做余弦相似度搜索,tenant_id 过滤由 RLS 自动加:

// kb/infrastructure/vector/pgvector.go
func (s *PgVectorStore) Search(ctx context.Context, tenantID string, ...) ([]domain.Hit, error) {
    rows, err := s.db.QueryContext(ctx, `
        SELECT id, document_id, content, ...,
               (1 - (embedding <=> $1::vector)) AS score
        FROM kb_chunks
        WHERE namespace_id = $2
          AND tenant_id    = $3       -- RLS 也会自动加这道过滤
          AND embedding IS NOT NULL
        ORDER BY embedding <=> $1::vector
        LIMIT $4`,
        formatVec(vec), string(ns), tenantID, topK,
    )
}

同样,记忆(Memory)的向量检索也走 pgvector,存在 memories 表的 embedding 列里——也在 RLS 保护范围内。

也就是说,RLS 不只是管"普通关系数据",连向量检索一起管了。知识库、记忆这些"高级功能"的隔离,不需要额外的安全层,和普通查询共用同一套 RLS 策略。

代码里保留了 Qdrant 适配器(infrastructure/vector/qdrant.go)作为备选方案——如果将来部署需要独立的向量服务,可以切换。但当前 cmd/kb/main.go wire 的是 pgvector。


为什么在 Domain 层也校验

仅靠 RLS 有一个风险:current_setting('app.tenant_id', true) 如果传入空字符串,某些 PostgreSQL 配置下可能绕过 RLS,导致能查到所有租户的数据。

所以在 Domain 层的构造函数里也做了校验:

// agent/domain/model/session.go
var ErrMissingIdentity = errors.New("session: tenantID and userID are required")

func NewSession(tenantID, userID, agentConfig string) (*Session, error) {
    if tenantID == "" || userID == "" {
        return nil, ErrMissingIdentity
    }
    // ...
}

两道防线:

  1. Domain 层ErrMissingIdentity 拦截空 tenantID,阻止创建无效会话
  2. 数据库层:RLS 策略强制过滤,即使绕过了 Domain 层,数据库也不会返回其他租户数据

Domain 层校验比放在 HTTP handler 里更可靠——不管请求从 REST、gRPC 还是任何其他入口进来,这里都拦得住。

两道防线就像银行的金库:前台(Domain 层)先查身份证,金库门禁(RLS)再刷卡确认。即使前台疏忽了,门禁也不会放行。


运营后台的特殊处理

平台管理员需要跨租户查看数据(比如运营后台要看所有租户的统计)。实际做法不是在 RLS 策略里加 OR 条件,而是用 独立的 PostgreSQL role 绕过 RLS

-- 在 docker-compose init.sql 中:
CREATE ROLE df_ops BYPASSRLS;
GRANT df_ops TO ops_admin;

BYPASSRLS 是 PostgreSQL 原生权限,拥有这个 role 的连接自动跳过所有 RLS 策略。普通服务的连接池用的是 df_app role(受 RLS 约束),运营后台用的是 df_ops role(绕过 RLS)。

这个设计的精妙在于:权限控制在数据库连接层,不是靠应用代码自律。普通服务的连接池物理上无法设置 BYPASSRLS,想绕都绕不了。


对象存储的隔离:Garage

PostgreSQL 里的数据(包括向量)由 RLS 保护,那文件(上传的文档、附件等)怎么隔离?

DeepFlux 当前使用 Garage 作为对象存储。Garage 是一个轻量级的、S3 兼容的分布式存储服务,自托管、无外部依赖,非常适合私有化部署场景。

# deploy/dev/garage.toml
metadata_dir = "/var/lib/garage/meta"
data_dir     = "/var/lib/garage/data"
s3_region    = "garage"   # region 固定为 "garage"

代码通过统一的 ObjectStore 抽象层接入,默认走 S3 协议连接 Garage:

// storage/factory.go
func NewObjectStore(cfg Config) (ObjectStore, error) {
    switch cfg.Backend {
    case "local":
        return NewLocalStore(cfg.RootDir), nil   // dev 单节点:本地磁盘
    default: // "s3", "garage", ""
        return NewS3Store(cfg)                    // 生产:Garage(S3 兼容协议)
    }
}

因为 Garage 完全兼容 S3 协议,S3Store 直接就能对接——配置里填 Endpoint="http://garage:3900"Region="garage" 即可。将来如果需要换成腾讯 COS、阿里 OSS 或 AWS S3,只改配置,代码不动。

多租户隔离靠路径前缀:每个租户的文件存储在 /<tenant_id>/... 路径下,Garage 的 IAM 策略限制每个服务只能访问对应租户的路径。

你可以把它想象成每人一个带锁的储物柜:柜子编号就是租户 ID,只有持有对应钥匙的服务才能打开。本地开发模式(Backend="local")相当于大家共用一个开放架子——没有真正的隔离,但开发环境无所谓。


小结

多租户隔离的核心思路是尽量让一种机制覆盖所有数据

  1. PostgreSQL RLS 是主力:12 张表批量启用,连向量(pgvector)也在保护范围内
  2. Domain 层早校验ErrMissingIdentity 拦空 tenantID,双保险
  3. 运营绕行用独立 PG roleBYPASSRLS 在连接层控制,不是应用层判断
  4. 文件存储用 Garage + 路径前缀:S3 兼容对象存储,按 <tenant_id>/ 前缀隔离

最关键的设计决策是把向量也存进 PostgreSQL(pgvector),而不是用独立的向量数据库——这样 RLS 一套策略就能覆盖结构化数据、向量检索、全文搜索,不需要为每种存储单独做隔离。


下一篇:审计日志 —— append-only 的合规链路

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐