企业知识库的文档权限控制——不是所有人都能看所有答案
企业知识库的文档权限控制——不是所有人都能看所有答案
事情是这样发生的。
去年 11 月,我们公司的 RAG 知识库上线了一个月,用的人越来越多,产品经理觉得很满意。然后有一天,一个开发同学在用知识库查"公司的年终奖发放规则",系统返回了一份文档,里面包含了各个级别员工的薪资区间和绩效奖金计算方式。
那份文档是 HR 上传的,本来只应该 HR 和各部门负责人能看到。
我当时的处理方式也很蠢:赶紧把那几份 HR 文档从向量数据库里删掉,然后告诉 HR 组"先别上传敏感文档"。这相当于把权限问题转嫁给了业务部门。
问题当然是治标不治本。三个月后,知识库里的文档越来越多,类似的问题接二连三出现——财务预算文件被普通员工查到了,高管薪酬的文档也差点泄露。
这才让我认真思考:企业知识库的权限控制,不是在应用层加个"这个人能不能访问"就够了,得深入到向量数据库层面来设计。
权限控制的两个层次
很多团队做权限控制只做了第一层:应用层鉴权。
逻辑是:用户访问知识库前先验证身份,判断这个用户属于什么角色、什么部门,然后决定"允不允许他用这个知识库功能"。
这种方式对于"能不能使用这个系统"的控制是够的,但对于"能看哪些文档内容"的控制是不够的。
因为向量检索是一个黑盒过程——你可以限制入口,但你没有控制检索过程本身。只要用户能用这个系统,他就能检索到所有被索引的文档。
真正需要的是第二层:向量数据库层面的文档可见性控制。
这层控制的逻辑是:在检索阶段就进行过滤,让不同用户只能从"对他们可见的文档子集"里检索。这样从根本上解决了问题——你查不到的文档,RAG 就不可能把它的内容包含进答案里。
基于角色的文档可见性模型
先设计权限模型。我们用的是 RBAC(基于角色的访问控制)和文档 metadata 的组合。
每个文档在入库时,都打上权限标签:
@Data
@Builder
public class DocumentPermission {
/**
* 文档 ID
*/
private String documentId;
/**
* 可见部门列表(空列表表示所有部门可见)
*/
private List<String> visibleDepartments;
/**
* 最低可见角色级别(1=普通员工, 2=组长, 3=经理, 4=总监, 5=高管)
*/
private int minRoleLevel;
/**
* 是否全员可见
*/
private boolean publicVisible;
/**
* 具体白名单用户 ID(优先级最高)
*/
private List<String> whitelistUserIds;
}文档上传时,构建带权限的 metadata:
@Service
@Slf4j
public class DocumentIngestionService {
private final VectorStore vectorStore;
private final DocumentSplitter splitter;
public void ingestDocument(MultipartFile file, DocumentPermission permission) {
// 1. 解析文档
Document rawDoc = parseDocument(file);
// 2. 分块
List<Document> chunks = splitter.split(rawDoc);
// 3. 为每个块添加权限 metadata
List<Document> permissionedChunks = chunks.stream()
.map(chunk -> {
Map<String, Object> metadata = new HashMap<>(chunk.getMetadata());
// 权限信息写入 metadata
metadata.put("public_visible", permission.isPublicVisible());
metadata.put("min_role_level", permission.getMinRoleLevel());
metadata.put("visible_departments",
String.join(",", permission.getVisibleDepartments()));
metadata.put("whitelist_users",
String.join(",", permission.getWhitelistUserIds()));
// 文档来源信息
metadata.put("source_file", file.getOriginalFilename());
metadata.put("ingest_time", System.currentTimeMillis());
return new Document(chunk.getId(), chunk.getContent(), metadata);
})
.collect(Collectors.toList());
// 4. 写入向量数据库
vectorStore.add(permissionedChunks);
log.info("Ingested {} chunks for document: {}", chunks.size(), file.getOriginalFilename());
}
}Milvus 的实现方案:Partition + Metadata Filter
这里有个重要的架构决策要说清楚。
Milvus(以及很多向量数据库)支持两种隔离机制:
- Partition(物理分区):不同分区的数据物理隔离,检索时可以只搜特定分区
- Metadata Filter(元数据过滤):检索时通过 where 条件过滤
这两个不是二选一,配合使用效果最好:大类隔离用 Partition,细粒度控制用 Metadata Filter。
我们的实践:
- Partition 按部门大类划分:
public、hr、finance、tech、executive - Metadata Filter 做细粒度控制:角色级别、具体用户白名单
@Service
@Slf4j
public class MilvusPermissionService {
private final MilvusServiceClient milvusClient;
@Value("${milvus.collection.name:enterprise_docs}")
private String collectionName;
/**
* 根据用户身份构建 Milvus 检索过滤表达式
*/
public String buildPermissionFilter(UserContext userContext) {
List<String> conditions = new ArrayList<>();
// 条件 1:公开文档所有人可见
conditions.add("public_visible == true");
// 条件 2:用户在白名单里
if (!userContext.getUserId().isEmpty()) {
conditions.add(String.format(
"array_contains(whitelist_users, \"%s\")",
userContext.getUserId()
));
}
// 条件 3:用户部门在可见部门里
if (userContext.getDepartment() != null) {
conditions.add(String.format(
"visible_departments like \"%%%s%%\"",
userContext.getDepartment()
));
}
// 组合:满足任意条件即可(OR),再加角色级别限制(AND)
String visibilityCondition = "(" + String.join(" || ", conditions) + ")";
String roleCondition = String.format(
"min_role_level <= %d",
userContext.getRoleLevel()
);
return visibilityCondition + " && " + roleCondition;
}
/**
* 带权限过滤的向量检索
*/
public List<Document> searchWithPermission(
float[] queryVector,
int topK,
UserContext userContext) {
String filter = buildPermissionFilter(userContext);
log.debug("Search filter for user {}: {}", userContext.getUserId(), filter);
// 确定要搜索的 Partition
List<String> partitions = getAccessiblePartitions(userContext);
SearchParam searchParam = SearchParam.newBuilder()
.withCollectionName(collectionName)
.withPartitionNames(partitions)
.withVectors(Collections.singletonList(Arrays.asList(toFloatList(queryVector))))
.withTopK(topK)
.withExpr(filter)
.withMetricType(MetricType.COSINE)
.addOutField("content")
.addOutField("source_file")
.addOutField("min_role_level")
.build();
R<SearchResults> response = milvusClient.search(searchParam);
if (response.getStatus() != R.Status.Success.getCode()) {
log.error("Milvus search failed: {}", response.getMessage());
throw new RuntimeException("Search failed: " + response.getMessage());
}
return convertToDocuments(response.getData());
}
private List<String> getAccessiblePartitions(UserContext userContext) {
List<String> partitions = new ArrayList<>();
partitions.add("public"); // 所有人都能搜公开分区
if (userContext.getRoleLevel() >= 3) { // 经理及以上
partitions.add(userContext.getDepartment().toLowerCase());
}
if (userContext.getRoleLevel() >= 5) { // 高管
partitions.add("executive");
partitions.add("finance");
partitions.add("hr");
}
return partitions;
}
}用户上下文:
@Data
@Builder
public class UserContext {
private String userId;
private String username;
private String department; // e.g., "tech", "finance", "hr"
private int roleLevel; // 1-5
private List<String> roles; // e.g., ["developer", "tech_lead"]
/**
* 从 JWT Token 中解析用户上下文
*/
public static UserContext fromJwt(String jwtToken) {
// 解析 JWT,获取用户信息
// 这里省略具体实现,根据你们的认证体系来
Claims claims = parseJwt(jwtToken);
return UserContext.builder()
.userId(claims.getSubject())
.department(claims.get("dept", String.class))
.roleLevel(claims.get("role_level", Integer.class))
.build();
}
}集成到 Spring AI 的 VectorStore
Spring AI 支持自定义 VectorStore,我们把权限过滤集成进去:
@Component
@Slf4j
public class PermissionAwareVectorStore implements VectorStore {
private final MilvusPermissionService milvusService;
private final EmbeddingClient embeddingClient;
// ThreadLocal 存储当前用户上下文
private static final ThreadLocal<UserContext> CURRENT_USER = new ThreadLocal<>();
public static void setCurrentUser(UserContext userContext) {
CURRENT_USER.set(userContext);
}
public static void clearCurrentUser() {
CURRENT_USER.remove();
}
public PermissionAwareVectorStore(MilvusPermissionService milvusService,
EmbeddingClient embeddingClient) {
this.milvusService = milvusService;
this.embeddingClient = embeddingClient;
}
@Override
public List<Document> similaritySearch(SearchRequest request) {
UserContext userContext = CURRENT_USER.get();
if (userContext == null) {
log.warn("No user context set, using anonymous access");
// 匿名访问只能看公开文档
userContext = UserContext.builder()
.userId("anonymous")
.department("")
.roleLevel(0)
.build();
}
// 生成 Query 向量
float[] queryVector = embeddingClient.embed(request.getQuery());
return milvusService.searchWithPermission(
queryVector,
request.getTopK(),
userContext
);
}
@Override
public void add(List<Document> documents) {
// 写入时不需要权限控制,由管理员或服务账号执行
// 具体实现省略
}
@Override
public Optional<Boolean> delete(List<String> idList) {
// 删除实现省略
return Optional.of(true);
}
}在 Controller 层设置用户上下文:
@RestController
@RequestMapping("/api/knowledge")
@Slf4j
public class KnowledgeQueryController {
private final RagService ragService;
@PostMapping("/query")
public ResponseEntity<QueryResponse> query(
@RequestBody QueryRequest request,
@RequestHeader("Authorization") String authHeader) {
// 从 JWT 解析用户身份
UserContext userContext = UserContext.fromJwt(
authHeader.replace("Bearer ", "")
);
try {
// 设置用户上下文(线程安全)
PermissionAwareVectorStore.setCurrentUser(userContext);
String answer = ragService.query(request.getQuestion());
return ResponseEntity.ok(QueryResponse.builder()
.answer(answer)
.userId(userContext.getUserId())
.build());
} finally {
// 一定要清理,避免线程池复用时上下文残留
PermissionAwareVectorStore.clearCurrentUser();
}
}
}这里的 finally 块非常重要。如果不清理,在线程池环境里,下一个用户的请求可能会带着上一个用户的权限上下文执行检索,造成权限逃逸。我特别叮嘱团队,凡是用 ThreadLocal 的地方,必须有配套的清理逻辑。
权限的动态更新
文档上传后权限不是一成不变的。HR 可能说"这份文档以前只有总监能看,现在开放给全体员工了"。
这就需要支持权限的动态更新:
@Service
@Slf4j
public class DocumentPermissionUpdateService {
private final MilvusServiceClient milvusClient;
@Value("${milvus.collection.name:enterprise_docs}")
private String collectionName;
/**
* 批量更新文档权限
* Milvus 不支持直接 update metadata,需要先查出来再删除重插
*/
@Transactional
public void updatePermission(String sourceFile, DocumentPermission newPermission) {
log.info("Updating permission for document: {}", sourceFile);
// 1. 查出所有属于这个文件的文档块
QueryParam queryParam = QueryParam.newBuilder()
.withCollectionName(collectionName)
.withExpr(String.format("source_file == \"%s\"", sourceFile))
.addOutField("id")
.addOutField("content")
.addOutField("embedding")
.build();
R<QueryResults> queryResult = milvusClient.query(queryParam);
List<Map<String, Object>> existingChunks = extractChunks(queryResult);
if (existingChunks.isEmpty()) {
log.warn("No chunks found for file: {}", sourceFile);
return;
}
// 2. 删除旧数据
List<String> idsToDelete = existingChunks.stream()
.map(chunk -> chunk.get("id").toString())
.collect(Collectors.toList());
DeleteParam deleteParam = DeleteParam.newBuilder()
.withCollectionName(collectionName)
.withExpr(String.format("id in %s", idsToDelete))
.build();
milvusClient.delete(deleteParam);
// 3. 重新插入,带新的权限 metadata
List<Document> updatedChunks = existingChunks.stream()
.map(chunk -> {
Map<String, Object> metadata = new HashMap<>();
metadata.put("public_visible", newPermission.isPublicVisible());
metadata.put("min_role_level", newPermission.getMinRoleLevel());
metadata.put("visible_departments",
String.join(",", newPermission.getVisibleDepartments()));
metadata.put("source_file", sourceFile);
return new Document(
chunk.get("id").toString(),
chunk.get("content").toString(),
metadata
);
})
.collect(Collectors.toList());
insertWithEmbeddings(updatedChunks, existingChunks);
log.info("Updated permission for {} chunks of file: {}",
updatedChunks.size(), sourceFile);
}
}审计日志——事后追溯
权限控制做好了,还需要审计日志。出了问题能追溯,谁在什么时候查了什么。
@Aspect
@Component
@Slf4j
public class QueryAuditAspect {
private final QueryAuditRepository auditRepository;
@Around("execution(* com.example.rag.service.RagService.query(..))")
public Object auditQuery(ProceedingJoinPoint joinPoint) throws Throwable {
UserContext userContext = PermissionAwareVectorStore.getCurrentUser();
String question = (String) joinPoint.getArgs()[0];
long startTime = System.currentTimeMillis();
Object result = null;
Exception error = null;
try {
result = joinPoint.proceed();
return result;
} catch (Exception e) {
error = e;
throw e;
} finally {
// 无论成功失败,都记录审计日志
QueryAuditLog auditLog = QueryAuditLog.builder()
.userId(userContext != null ? userContext.getUserId() : "unknown")
.department(userContext != null ? userContext.getDepartment() : "unknown")
.roleLevel(userContext != null ? userContext.getRoleLevel() : 0)
.question(question)
.questionHash(hashQuestion(question)) // 敏感场景不记录原文,只记录哈希
.success(error == null)
.errorMessage(error != null ? error.getMessage() : null)
.latencyMs(System.currentTimeMillis() - startTime)
.timestamp(LocalDateTime.now())
.build();
auditRepository.save(auditLog);
}
}
}这个审计日志帮我们发现了一个有意思的现象:有个员工在一周内查了 200 多次和"高管薪酬"相关的问题。权限系统把内容过滤了,但他一直在尝试不同的问题措辞。这本身就是一个安全信号。
一个容易忽视的问题:答案本身的信息泄露
我们把文档权限做好了,但还有一个漏洞:即使 RAG 没有直接引用无权限文档,LLM 生成的答案里也可能间接泄露信息。
比如,用户问"公司有没有关于高管薪酬的规定?"。向量检索过滤了高管文档,但可能把一些侧面提及高管薪酬的文档(比如某个会议纪要)检索出来了,LLM 就可能从这些文档里推断出部分信息。
这个问题更难解,我们的处理方式是:对于某些特别敏感的 topic,直接在 Prompt 层加保护:
@Service
public class SensitiveTopicGuard {
private static final List<String> SENSITIVE_TOPICS = List.of(
"高管薪酬", "executive salary", "年终奖分配", "绩效评级结果",
"裁员计划", "layoff", "并购", "merger"
);
public boolean containsSensitiveTopic(String question) {
String lowerQuestion = question.toLowerCase();
return SENSITIVE_TOPICS.stream()
.anyMatch(topic -> lowerQuestion.contains(topic.toLowerCase()));
}
/**
* 如果问题涉及敏感话题,且用户权限不够,直接拒绝
*/
public void checkAndReject(String question, UserContext userContext) {
if (containsSensitiveTopic(question) && userContext.getRoleLevel() < 4) {
throw new AccessDeniedException(
"该问题涉及受限信息,您当前的权限级别无法查询此类内容。"
);
}
}
}这层保护是"Topic-level"的,而不是"Document-level"的,作为最后一道防线来用。
权限体系设计的几个原则
经历了这些踩坑,我总结出几个做企业知识库权限的原则:
1. 防御性默认(Default Deny)
文档入库时没有明确设置权限的,默认是"不可见",而不是"全员可见"。宁可用户说"我查不到",也不要发生意外泄露。
2. 最小权限原则
用户只能访问完成工作所需的最小范围文档。不要因为方便就把文档权限设得太宽。
3. 权限变更要有审批流
不能让文档上传者自己随便改权限,尤其是扩大权限范围的变更,要走审批。我们加了一个 PR-like 的权限变更审批机制。
4. 定期权限审查
员工离职、调岗之后,他之前上传的文档权限需要重新检视。我们设了季度审查,让各部门管理员确认自己部门文档的权限设置还是合理的。
总结
企业知识库的权限控制,应用层鉴权只是入口,向量数据库层的文档过滤才是核心。两层配合缺一不可。
Milvus Partition + Metadata Filter 的组合,在我们的场景里效果很好。当然,如果你用的是 Pinecone、Weaviate 或者其他向量数据库,实现方式不同,但核心思路是一样的:权限信息要作为 metadata 写入文档,检索时要作为 filter 条件应用。
最后,权限体系建好之后,定期的安全审计比技术实现更重要。
