AI 应用如何防止用户越权访问知识库
系列:从零构建企业 RAG 知识库(第 17 篇)
1. 越权不只发生在向量检索
攻击路径可能出现在:
- 猜测文档 ID 直接下载原文;
- 缓存复用其他用户答案;
- 关键词索引有权限、向量索引没有;
- 调试接口返回全库候选;
- 历史会话保留撤权前资料;
- 模型工具接受用户提交的租户 ID。
因此需要纵深防御,而不是一个过滤条件。
2. 可信身份与资源请求分离
python
from dataclasses import dataclass
@dataclass(frozen=True)
class Identity:
user_id: str
tenant_id: str
permission_version: str
roles: frozenset[str]
@dataclass(frozen=True)
class KnowledgeRequest:
question: str
requested_document_ids: frozenset[str]
请求可以表达"想查哪些文档",但不能声明"我属于哪个租户"。
3. 交集而不是信任请求范围
python
def effective_scope(
request: KnowledgeRequest,
authorized_document_ids: frozenset[str],
) -> frozenset[str]:
if not request.question.strip():
raise ValueError("问题不能为空")
if request.requested_document_ids:
return request.requested_document_ids & authorized_document_ids
return authorized_document_ids
客户端请求未授权文档时,结果只能缩小,绝不能扩大。
4. 查询后再次验证结果
预过滤是主边界,结果复核是纵深防御:
python
@dataclass(frozen=True)
class RetrievedChunk:
chunk_id: str
document_id: str
tenant_id: str
text: str
def enforce_result_scope(
identity: Identity,
chunks: list[RetrievedChunk],
authorized_document_ids: frozenset[str],
) -> list[RetrievedChunk]:
violations = [
item.chunk_id for item in chunks
if item.tenant_id != identity.tenant_id
or item.document_id not in authorized_document_ids
]
if violations:
# 不能静默丢弃后继续回答,否则安全缺陷会被掩盖
raise PermissionError(f"检索器返回越权结果:{violations}")
return chunks
如果结果复核失败,应阻断请求并触发安全告警。
5. 权限感知缓存
python
from hashlib import sha256
def answer_cache_key(
identity: Identity,
question: str,
scope: frozenset[str],
knowledge_version: str,
) -> str:
raw = "|".join([
identity.tenant_id,
identity.user_id,
identity.permission_version,
knowledge_version,
",".join(sorted(scope)),
question.strip(),
])
return sha256(raw.encode("utf-8")).hexdigest()
用户撤权后 permission_version 改变,旧缓存不再命中。高敏内容可不缓存。
6. 原文下载使用短时授权
python
from datetime import datetime, timezone
@dataclass(frozen=True)
class DownloadGrant:
user_id: str
document_id: str
expires_at: datetime
def validate_download_grant(
grant: DownloadGrant,
identity: Identity,
authorized_document_ids: frozenset[str],
now: datetime,
) -> None:
if grant.user_id != identity.user_id:
raise PermissionError("下载授权不属于当前用户")
if now >= grant.expires_at:
raise PermissionError("下载授权已过期")
if grant.document_id not in authorized_document_ids:
raise PermissionError("当前已无文档访问权限")
生产授权还要有不可伪造签名或服务端状态,数据类本身不是完整令牌方案。
7. 可复验负向测试
python
def test_requested_scope_can_only_shrink() -> None:
request = KnowledgeRequest("查询制度", frozenset({"allowed", "secret"}))
scope = effective_scope(request, frozenset({"allowed"}))
assert scope == frozenset({"allowed"})
def test_cross_tenant_result_blocks_entire_answer() -> None:
identity = Identity("u1", "tenant-a", "pv1", frozenset())
chunks = [RetrievedChunk("c1", "d1", "tenant-b", "秘密")]
try:
enforce_result_scope(identity, chunks, frozenset({"d1"}))
except PermissionError:
pass
else:
raise AssertionError("越权结果必须阻断")
8. 对抗性审查清单
- 每个入口都从可信会话读取身份;
- 关键词、向量、图和原文接口使用同一权限策略;
- 缓存、会话历史和导出同样隔离;
- 调试工具有生产禁用和审计;
- 权限变化使缓存、下载授权和会话失效;
- 工具参数中的租户 ID 被服务端覆盖;
- 持续测试水平越权、垂直越权和跨租户访问。
9. 总结
防越权的原则是:身份由服务端确定、检索前限制范围、检索后检测异常、缓存和原文再次鉴权。任何一层都不能把模型当权限裁判。