🔐 数据隔离

防止用户 A 看到用户 B 的数据——多租户 AI 产品的基础安全要求。2023 年 ChatGPT 的缓存 bug 泄露对话就是数据隔离失败的典型案例。

数据隔离的三个维度

                    数据隔离维度
                        │
            ┌───────────┼───────────┐
            │           │           │
     租户隔离      会话隔离      时序隔离
  (Tenant)     (Session)    (Temporal)
            │           │           │
    ┌───────┤     ┌─────┤     ┌─────┤
    │       │     │     │     │     │
  存储   计算   对话  上下文  冷数据 热数据
  隔离   隔离   历史  窗口   加密   缓存

租户隔离策略

策略描述隔离强度成本适用规模
共享数据库 + tenant_id所有租户共享表,用 tenant_id 过滤🟡 中小规模/内部工具
Schema 隔离同数据库,每个租户独立 schema🟢 较高中等规模
数据库隔离每个租户独立数据库实例🔴 高企业级/B2B
物理隔离不同服务器/区域🔴 极高极高金融/医疗

方案1: 共享数据库 + tenant_id(最常见)

from sqlalchemy import Column, String, Integer, Text, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, scoped_session
from contextlib import contextmanager
import uuid

Base = declarative_base()

class Conversation(Base):
    """对话记录表——包含 tenant_id"""
    __tablename__ = 'conversations'
    
    id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
    tenant_id = Column(String(36), nullable=False, index=True)  # 必须有索引!
    user_id = Column(String(36), nullable=False, index=True)
    title = Column(String(200))
    created_at = Column(Integer, nullable=False)

class Message(Base):
    """消息表"""
    __tablename__ = 'messages'
    
    id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
    tenant_id = Column(String(36), nullable=False, index=True)
    conversation_id = Column(String(36), nullable=False, index=True)
    role = Column(String(20), nullable=False)
    content = Column(Text, nullable=False)
    created_at = Column(Integer, nullable=False)

# 关键:所有查询必须带 tenant_id 条件
class TenantAwareQuery:
    """租户感知查询——强制添加 tenant_id 过滤"""
    
    def __init__(self, session_factory, tenant_id: str):
        self.session_factory = session_factory
        self.tenant_id = tenant_id
    
    def get_conversations(self, user_id: str, limit: int = 20):
        session = self.session_factory()
        try:
            return session.query(Conversation).filter(
                Conversation.tenant_id == self.tenant_id,  # 强制过滤
                Conversation.user_id == user_id
            ).order_by(Conversation.created_at.desc()).limit(limit).all()
        finally:
            session.close()
    
    def get_messages(self, conversation_id: str):
        session = self.session_factory()
        try:
            # 双重检查:conversation_id + tenant_id
            return session.query(Message).filter(
                Message.tenant_id == self.tenant_id,
                Message.conversation_id == conversation_id
            ).order_by(Message.created_at).all()
        finally:
            session.close()

# 使用
def get_tenant_query(tenant_id: str) -> TenantAwareQuery:
    return TenantAwareQuery(SessionFactory, tenant_id)

# 中间件:从 JWT 提取 tenant_id
def auth_middleware(request):
    token = request.headers.get('Authorization', '').replace('Bearer ', '')
    payload = verify_jwt(token)
    request.state.tenant_id = payload['tenant_id']
    request.state.user_id = payload['user_id']
⚠️ 共享数据库最大的风险:忘记在查询中加 tenant_id 过滤。这是一个看似简单但极容易犯的错误。建议在 ORM 层强制注入 tenant_id,而不是依赖开发者每次都记得加。

方案2: Row-Level Security (PostgreSQL RLS)

-- PostgreSQL Row-Level Security:数据库层面的租户隔离
-- 即使应用层忘记过滤,数据库也会阻止跨租户访问

-- 1. 启用 RLS
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;

-- 2. 创建策略:只能看到自己租户的数据
CREATE POLICY tenant_isolation ON conversations
    USING (tenant_id = current_setting('app.current_tenant')::text);

CREATE POLICY tenant_isolation ON messages
    USING (tenant_id = current_setting('app.current_tenant')::text);

-- 3. 应用层设置当前租户
-- 在每个请求开始时:
-- SET app.current_tenant = 'tenant_123';
-- 之后所有查询自动过滤

-- Python 集成
"""
from sqlalchemy import text

@app.middleware("http")
async def set_tenant_context(request, call_next):
    tenant_id = request.state.tenant_id
    # 设置 PostgreSQL 会话变量
    with engine.connect() as conn:
        conn.execute(text(f"SET app.current_tenant = '{tenant_id}'"))
        conn.commit()
    response = await call_next(request)
    return response
"""

对话历史加密

import os
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
import base64

class ConversationEncryptor:
    """对话历史加密器"""
    
    def __init__(self, master_key: str = None):
        """使用主密钥派生加密密钥"""
        if master_key is None:
            master_key = os.environ.get('ENCRYPTION_MASTER_KEY')
        if not master_key:
            raise ValueError("必须提供加密主密钥")
        
        # 使用 PBKDF2 派生密钥
        kdf = PBKDF2HMAC(
            algorithm=hashes.SHA256(),
            length=32,
            salt=b'openclaw-conversation-salt',  # 生产环境应该每租户不同
            iterations=480000,
        )
        key = base64.urlsafe_b64encode(kdf.derive(master_key.encode()))
        self.cipher = Fernet(key)
    
    def encrypt(self, plaintext: str) -> str:
        """加密对话内容"""
        return self.cipher.encrypt(plaintext.encode()).decode()
    
    def decrypt(self, ciphertext: str) -> str:
        """解密对话内容"""
        return self.cipher.decrypt(ciphertext.encode()).decode()
    
    def encrypt_message(self, role: str, content: str) -> dict:
        """加密单条消息"""
        return {
            'role': role,  # role 不加密(需要用于 API 调用)
            'content_encrypted': self.encrypt(content),
            'content_hash': hashlib.sha256(content.encode()).hexdigest()[:16]
        }

# 使用
encryptor = ConversationEncryptor()
encrypted = encryptor.encrypt("用户输入的敏感内容")
decrypted = encryptor.decrypt(encrypted)

# 存储时加密
msg = encryptor.encrypt_message("user", "我的银行卡号是6222...")
# {'role': 'user', 'content_encrypted': 'gAAAA...', 'content_hash': 'a1b2c3d4e5f6g7h8'}

# 读取时解密
original = encryptor.decrypt(msg['content_encrypted'])

日志脱敏

import re
import json
import logging
from typing import Any

class SanitizingFormatter(logging.Formatter):
    """日志脱敏格式化器"""
    
    PII_PATTERNS = {
        'email': (r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', '[EMAIL]'),
        'phone_cn': (r'1[3-9]\d{9}', '[PHONE]'),
        'id_card': (r'\d{17}[\dXx]', '[ID_CARD]'),
        'ip': (r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[IP]'),
        'api_key': (r'sk-[a-zA-Z0-9]{8,}[a-zA-Z0-9]*', 'sk-[REDACTED]'),
        'credit_card': (r'\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}', '[CARD]'),
    }
    
    def format(self, record: logging.LogRecord) -> str:
        # 先格式化
        result = super().format(record)
        # 再脱敏
        for pii_type, (pattern, replacement) in self.PII_PATTERNS.items():
            result = re.sub(pattern, replacement, result)
        return result

class SanitizedLogger:
    """安全日志记录器"""
    
    def __init__(self, name: str):
        self.logger = logging.getLogger(name)
        handler = logging.StreamHandler()
        handler.setFormatter(SanitizingFormatter(
            '%(asctime)s [%(levelname)s] %(message)s'
        ))
        self.logger.addHandler(handler)
        self.logger.setLevel(logging.INFO)
    
    def log_request(self, tenant_id: str, user_id: str, 
                    model: str, token_count: int, **kwargs):
        """记录 API 请求日志(不含用户输入内容)"""
        self.logger.info(json.dumps({
            'event': 'llm_request',
            'tenant_id': tenant_id[:8] + '***',  # 部分脱敏
            'user_id': user_id[:8] + '***',
            'model': model,
            'token_count': token_count,
            # 注意:不记录用户输入和模型输出内容
        }))
    
    def log_error(self, error: str, tenant_id: str = None, **kwargs):
        """记录错误日志"""
        safe_kwargs = {k: self._sanitize(v) for k, v in kwargs.items()}
        self.logger.error(json.dumps({
            'event': 'error',
            'error': error,
            'tenant_id': tenant_id[:8] + '***' if tenant_id else None,
            **safe_kwargs
        }))
    
    def _sanitize(self, value: Any) -> Any:
        """通用脱敏"""
        if isinstance(value, str):
            for pattern, replacement in SanitizingFormatter.PII_PATTERNS.items():
                value = re.sub(pattern[0], pattern[1], value)  # pattern is (regex, replacement)
            return value
        return value

# 使用
logger = SanitizedLogger('ai_service')
logger.log_request(
    tenant_id="tenant_abc123",
    user_id="user_xyz789",
    model="gpt-4o",
    token_count=1500
)
# 输出: {"event":"llm_request","tenant_id":"tenant_a***","user_id":"user_xy***","model":"gpt-4o","token_count":1500}

向量数据库的租户隔离

💡 特别注意:向量数据库(Pinecone、Weaviate、Qdrant、Milvus)的隔离是 AI 产品独有的挑战。如果用户 A 的文档 Embedding 和用户 B 的混在一起,检索时可能返回错误租户的数据。
from dataclasses import dataclass
from typing import Optional

# 方案1: Metadata 过滤(Pinecone / Qdrant / Weaviate 都支持)

class TenantAwareVectorStore:
    """租户感知的向量存储"""
    
    def __init__(self, client, index_name: str):
        self.client = client
        self.index = client.Index(index_name)
    
    def upsert(self, tenant_id: str, doc_id: str, 
               embedding: list[float], metadata: dict = None):
        """插入向量——强制附加 tenant_id"""
        vector = {
            'id': f"{tenant_id}_{doc_id}",  # 命名空间隔离
            'values': embedding,
            'metadata': {
                **(metadata or {}),
                'tenant_id': tenant_id  # 强制设置 tenant_id
            }
        }
        self.index.upsert(vectors=[vector])
    
    def query(self, tenant_id: str, query_embedding: list[float], 
              top_k: int = 5, filter: dict = None):
        """查询向量——强制过滤 tenant_id"""
        # 构建过滤条件
        tenant_filter = {'tenant_id': {'$eq': tenant_id}}
        if filter:
            # 合并用户自定义过滤
            tenant_filter = {'$and': [tenant_filter, filter]}
        
        results = self.index.query(
            vector=query_embedding,
            top_k=top_k,
            filter=tenant_filter,  # 强制租户过滤!
            include_metadata=True
        )
        return results

# Pinecone 具体实现
"""
import pinecone

pc = pinecone.Pinecone(api_key="your-key")
index = pc.Index("documents")

store = TenantAwareVectorStore(pc, "documents")

# 插入——自动附加 tenant_id
store.upsert(
    tenant_id="tenant_abc",
    doc_id="doc_123",
    embedding=[0.1, 0.2, ...],
    metadata={"source": "upload", "title": "合同文件"}
)

# 查询——自动过滤 tenant_id
results = store.query(
    tenant_id="tenant_abc",
    query_embedding=[0.15, 0.22, ...],
    top_k=5
)
# 只返回 tenant_abc 的数据,不会泄露其他租户的
"""

# 方案2: 独立命名空间/Collection(更强的隔离)

class NamespacedVectorStore:
    """使用命名空间实现强隔离"""
    
    def __init__(self, client):
        self.client = client
    
    def _get_namespace(self, tenant_id: str) -> str:
        """每个租户独立命名空间"""
        return f"tenant_{tenant_id}"
    
    def upsert(self, tenant_id: str, vectors: list[dict]):
        """在租户命名空间中插入"""
        namespace = self._get_namespace(tenant_id)
        index = self.client.Index("documents")
        index.upsert(vectors=vectors, namespace=namespace)
    
    def query(self, tenant_id: str, query_embedding: list[float], top_k: int = 5):
        """在租户命名空间中查询"""
        namespace = self._get_namespace(tenant_id)
        index = self.client.Index("documents")
        return index.query(
            vector=query_embedding,
            top_k=top_k,
            namespace=namespace  # 只搜索该命名空间
        )

# 方案3: 独立索引/Collection(最强隔离)
"""
# Qdrant: 每个 Collection 独立
from qdrant_client import QdrantClient

client = QdrantClient(url="http://localhost:6333")

def get_or_create_collection(tenant_id: str):
    collection_name = f"docs_{tenant_id}"
    try:
        client.get_collection(collection_name)
    except:
        client.create_collection(
            collection_name=collection_name,
            vectors_config=VectorParams(size=1536, distance=Distance.COSINE)
        )
    return collection_name
"""
向量库隔离方案隔离强度查询性能成本适用场景
Metadata 过滤🟡 中略慢(需过滤)大多数 SaaS
命名空间隔离🟢 高正常B2B 企业级
独立 Collection🔴 极高正常金融/医疗

数据隔离检查清单

## 数据隔离检查清单

### 存储层
[ ] 所有数据表包含 tenant_id 字段
[ ] tenant_id 字段有索引
[ ] 所有查询强制带 tenant_id 过滤(ORM 层或 RLS)
[ ] 考虑 PostgreSQL Row-Level Security
[ ] 对话历史加密存储
[ ] 加密密钥与租户绑定(而非全局共享)
[ ] 备份也按租户隔离

### 计算层
[ ] LLM 调用上下文不跨租户
[ ] Agent 工具调用范围限制在租户内
[ ] 缓存键包含 tenant_id(防止缓存混淆)
[ ] 临时文件/沙箱按租户隔离

### 日志层
[ ] 日志中不含用户原始输入/输出
[ ] PII 自动脱敏
[ ] tenant_id 部分脱敏
[ ] API Key 不出现在日志中
[ ] 日志访问有权限控制

### 向量数据库
[ ] 所有查询强制 metadata 过滤 tenant_id
[ ] 或使用命名空间/Collection 隔离
[ ] Embedding 元数据包含 tenant_id
[ ] 检索结果二次验证 tenant_id
[ ] 向量数据导出按租户隔离

### API 层
[ ] JWT/Token 包含 tenant_id
[ ] API 端点自动注入 tenant_id
[ ] 不允许客户端指定 tenant_id(从 Token 提取)
[ ] 批量操作限制在单个租户内

### 测试
[ ] 跨租户访问测试(A 不能访问 B 的数据)
[ ] IDOR 漏洞测试
[ ] 缓存混淆测试
[ ] 向量检索隔离测试

← 上一篇:输出过滤 | 下一篇:GDPR 合规 → | 返回首页