多租户隔离:一条 RLS 策略怎么防数据串

系列「企业级 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:

sql 复制代码
-- 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 注入进去:

sql 复制代码
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 保护:

sql 复制代码
-- 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 自动加:

go 复制代码
// 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 层的构造函数里也做了校验:

go 复制代码
// 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

sql 复制代码
-- 在 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 兼容的分布式存储服务,自托管、无外部依赖,非常适合私有化部署场景。

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

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

go 复制代码
// 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 的合规链路

相关推荐
leeyi2 小时前
长期记忆:Agent 怎么“记住“用户
llm·agent
leeyi2 小时前
工具调用:Agent 的手和眼
llm·agent
leeyi2 小时前
多 LLM Provider:不改一行业务代码换模型
llm·agent
leeyi2 小时前
SSE 实时推流 —— Token 怎么一个个蹦出来
后端·agent
凌奕2 小时前
微信小程序接入微信 AI:让用户"说一句话"就能下单
微信·微信小程序·agent
leeyi2 小时前
Hook 系统:插件化安全护栏怎么设计
llm·agent
Nicander2 小时前
去除中文写作AI味的Skill:write-like-human-zh
agent
leeyi2 小时前
ReAct 循环的 50 行 Go 实现,逐行拆解
后端·agent
leeyi2 小时前
HITL:让人类随时叫停 AI,并且能优雅地继续
后端·agent