第1638篇:Spring AI与Spring Security集成——多租户AI应用的权限隔离
2026/4/30大约 8 分钟
第1638篇:Spring AI与Spring Security集成——多租户AI应用的权限隔离
多租户是企业级SaaS应用绕不开的话题。当你在做一个B2B的AI产品,需要同时服务多个企业客户,每个客户的数据和AI能力必须完全隔离,这时候权限设计就变得相当复杂。
我去年参与了一个多租户AI平台的建设,遇到了不少坑,今天来聊聊怎么在Spring Security的基础上做好多租户AI应用的权限隔离。
多租户AI应用的核心隔离需求
先明确需要隔离什么:
数据隔离
├── 知识库隔离(每个租户的RAG数据独立)
├── 对话历史隔离
└── 自定义Prompt模板隔离
AI能力隔离
├── 模型选择隔离(不同套餐用不同模型)
├── 调用配额隔离(Token限额)
└── 功能开关隔离(哪些功能可用)
操作权限隔离
├── 用户管理权限
├── 知识库管理权限
└── 配置管理权限这三层隔离需要不同的技术手段来实现。
租户上下文的设计
整个多租户系统的基础是租户上下文——在一个请求的整个生命周期里,我们要知道当前是哪个租户在操作:
@Data
@Builder
public class TenantContext {
private String tenantId;
private String tenantName;
private TenantPlan plan; // 套餐类型
private Set<String> enabledFeatures; // 功能列表
private AIQuotaConfig quotaConfig; // 配额配置
public enum TenantPlan {
STARTER, // 入门版:基础功能,Token限额低
PROFESSIONAL,// 专业版:全部功能,Token限额高
ENTERPRISE // 企业版:自定义模型,无限制
}
public boolean isFeatureEnabled(String featureName) {
return enabledFeatures != null && enabledFeatures.contains(featureName);
}
public boolean canUseModel(String modelName) {
return switch (plan) {
case STARTER -> "gpt-3.5-turbo".equals(modelName) ||
"qwen-7b".equals(modelName);
case PROFESSIONAL -> !modelName.contains("enterprise");
case ENTERPRISE -> true;
};
}
}
// 用ThreadLocal存储当前请求的租户上下文
public class TenantContextHolder {
private static final ThreadLocal<TenantContext> CONTEXT = new ThreadLocal<>();
public static void set(TenantContext context) {
CONTEXT.set(context);
}
public static TenantContext get() {
TenantContext ctx = CONTEXT.get();
if (ctx == null) {
throw new TenantContextNotFoundException("租户上下文未设置,可能是未认证请求");
}
return ctx;
}
public static Optional<TenantContext> getOptional() {
return Optional.ofNullable(CONTEXT.get());
}
public static void clear() {
CONTEXT.remove();
}
}Spring Security配置
JWT里带租户信息,登录时验证并加载租户上下文:
@Component
@Slf4j
public class TenantAwareJwtAuthFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final TenantService tenantService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
try {
String token = extractToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
// 从JWT中解析用户信息和租户ID
JwtClaims claims = jwtTokenProvider.parseClaims(token);
String userId = claims.getUserId();
String tenantId = claims.getTenantId();
String userRole = claims.getRole();
// 加载租户详细配置(带本地缓存)
TenantContext tenantContext = tenantService.loadTenantContext(tenantId);
TenantContextHolder.set(tenantContext);
// 构建Spring Security的认证信息
List<GrantedAuthority> authorities = buildAuthorities(userRole, tenantContext);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(
new TenantAwareUserDetails(userId, tenantId, tenantContext),
null,
authorities
);
SecurityContextHolder.getContext().setAuthentication(auth);
log.debug("认证成功: userId={}, tenantId={}, plan={}",
userId, tenantId, tenantContext.getPlan());
}
} catch (Exception e) {
log.warn("JWT认证失败: {}", e.getMessage());
TenantContextHolder.clear();
}
try {
filterChain.doFilter(request, response);
} finally {
// 必须清理,避免线程复用时污染
TenantContextHolder.clear();
SecurityContextHolder.clearContext();
}
}
private List<GrantedAuthority> buildAuthorities(String role, TenantContext ctx) {
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()));
// 把租户功能作为权限
if (ctx.isFeatureEnabled("advanced-rag")) {
authorities.add(new SimpleGrantedAuthority("FEATURE_ADVANCED_RAG"));
}
if (ctx.isFeatureEnabled("custom-model")) {
authorities.add(new SimpleGrantedAuthority("FEATURE_CUSTOM_MODEL"));
}
if (ctx.getPlan() == TenantContext.TenantPlan.ENTERPRISE) {
authorities.add(new SimpleGrantedAuthority("PLAN_ENTERPRISE"));
}
return authorities;
}
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
private final TenantAwareJwtAuthFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/**").authenticated()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}知识库的多租户隔离
知识库(RAG的数据源)是多租户隔离里最复杂的部分。主要有两种方案:
方案一:namespace隔离(推荐)
用向量库的namespace或collection来隔离不同租户的数据,逻辑简单,性能好:
@Service
@Slf4j
public class TenantAwareVectorStore {
private final VectorStore vectorStore;
public TenantAwareVectorStore(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
/**
* 添加文档(自动打上租户标签)
*/
public void add(List<Document> documents) {
String tenantId = TenantContextHolder.get().getTenantId();
// 给每个文档打上租户ID的metadata
List<Document> taggedDocs = documents.stream()
.map(doc -> {
Map<String, Object> metadata = new HashMap<>(doc.getMetadata());
metadata.put("tenant_id", tenantId);
metadata.put("indexed_at", System.currentTimeMillis());
return Document.builder()
.content(doc.getContent())
.metadata(metadata)
.build();
})
.collect(Collectors.toList());
vectorStore.add(taggedDocs);
log.info("租户 {} 添加了 {} 个文档", tenantId, taggedDocs.size());
}
/**
* 语义搜索(自动限定在当前租户范围内)
*/
public List<Document> search(String query, int topK) {
String tenantId = TenantContextHolder.get().getTenantId();
// Filter表达式确保只返回当前租户的数据
SearchRequest request = SearchRequest.query(query)
.withTopK(topK)
.withSimilarityThreshold(0.7)
.withFilterExpression("tenant_id == '" + tenantId + "'");
return vectorStore.similaritySearch(request);
}
/**
* 删除租户的所有数据(用于租户注销)
*/
public void deleteAllForTenant(String tenantId) {
// 需要向量库支持按metadata过滤删除
vectorStore.delete(List.of(
Filter.expression("tenant_id == '" + tenantId + "'")
));
log.info("已删除租户 {} 的所有向量数据", tenantId);
}
}方案二:分库隔离(高安全要求场景)
不同租户用完全独立的向量库实例,安全性最高但运维成本也最高:
@Service
@Slf4j
public class MultiTenantVectorStoreFactory {
// 每个租户对应一个VectorStore实例
private final Map<String, VectorStore> tenantStores = new ConcurrentHashMap<>();
private final VectorStoreProperties defaultProperties;
private final TenantVectorStoreConfigRepository configRepository;
public VectorStore getStoreForTenant(String tenantId) {
return tenantStores.computeIfAbsent(tenantId, this::createStoreForTenant);
}
private VectorStore createStoreForTenant(String tenantId) {
// 加载租户专属的向量库配置
TenantVectorStoreConfig config = configRepository.findByTenantId(tenantId)
.orElse(createDefaultConfig(tenantId));
log.info("为租户 {} 创建专属向量库实例", tenantId);
// 这里以PgVector为例,每个租户用独立的schema
PgVectorStoreConfig pgConfig = PgVectorStoreConfig.builder()
.schemaName("tenant_" + tenantId.replace("-", "_"))
.tableName("embeddings")
.build();
return new PgVectorStore(dataSource, embeddingModel, pgConfig);
}
}AI调用的权限控制
在ChatClient调用层加入租户感知的权限控制:
@Service
@Slf4j
public class TenantAwareChatService {
private final ChatClient defaultChatClient;
private final Map<String, ChatClient> modelClients;
private final TenantAwareVectorStore vectorStore;
private final TokenBudgetService budgetService;
@PreAuthorize("isAuthenticated()")
public ChatResponse chat(ChatRequest request) {
TenantContext tenant = TenantContextHolder.get();
// 检查模型权限
String requestedModel = request.getModel();
if (requestedModel != null && !tenant.canUseModel(requestedModel)) {
throw new InsufficientPermissionException(
String.format("您的套餐(%s)不支持使用模型 %s,请升级套餐",
tenant.getPlan(), requestedModel));
}
// 检查Token配额
if (!budgetService.hasAvailableQuota(tenant.getTenantId())) {
throw new QuotaExceededException("Token配额已用尽,请联系管理员扩容");
}
// 检查功能权限
if (request.isEnableRAG() && !tenant.isFeatureEnabled("rag")) {
log.warn("租户 {} 尝试使用未授权的RAG功能", tenant.getTenantId());
throw new InsufficientPermissionException("您的套餐不包含RAG功能");
}
// 选择模型(使用租户配置的默认模型)
String modelToUse = requestedModel != null ? requestedModel :
getDefaultModelForTenant(tenant);
ChatClient client = modelClients.getOrDefault(modelToUse, defaultChatClient);
// 构建上下文(加入租户信息)
String systemPrompt = buildSystemPromptForTenant(tenant, request.getSystemPrompt());
// 如果启用了RAG,用租户专属的向量库检索
List<Message> ragContext = List.of();
if (request.isEnableRAG() && tenant.isFeatureEnabled("rag")) {
ragContext = vectorStore.search(request.getUserInput(), 3)
.stream()
.map(doc -> (Message) new SystemMessage("[参考文档]\n" + doc.getContent()))
.collect(Collectors.toList());
}
return client.prompt()
.system(systemPrompt)
.messages(ragContext)
.user(request.getUserInput())
.advisors(a -> a
.param("tenantId", tenant.getTenantId())
.param("userId", getCurrentUserId()))
.call()
.chatResponse();
}
private String buildSystemPromptForTenant(TenantContext tenant,
String baseSystemPrompt) {
String prompt = baseSystemPrompt != null ? baseSystemPrompt :
"你是一个专业的AI助手。";
// 注入租户特定的约束
if (tenant.getPlan() == TenantContext.TenantPlan.STARTER) {
prompt += "\n注意:请提供简洁的回答,每次回复不超过300字。";
}
return prompt;
}
private String getDefaultModelForTenant(TenantContext tenant) {
return switch (tenant.getPlan()) {
case STARTER -> "gpt-3.5-turbo";
case PROFESSIONAL -> "gpt-4o";
case ENTERPRISE -> "gpt-4"; // 或者企业自定义模型
};
}
private String getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof TenantAwareUserDetails details) {
return details.getUserId();
}
return "unknown";
}
}细粒度的方法级权限控制
用Spring Security的@PreAuthorize注解做方法级权限控制:
@RestController
@RequestMapping("/api/v1/knowledge-base")
@Slf4j
public class KnowledgeBaseController {
private final KnowledgeBaseService kbService;
// 查看知识库:所有认证用户都能看(只能看自己租户的)
@GetMapping
@PreAuthorize("isAuthenticated()")
public Page<KnowledgeBase> list(Pageable pageable) {
return kbService.listForCurrentTenant(pageable);
}
// 上传文档:需要知识库管理权限
@PostMapping("/{kbId}/documents")
@PreAuthorize("hasAuthority('KB_WRITE') or hasRole('TENANT_ADMIN')")
public Document upload(@PathVariable String kbId,
@RequestBody UploadDocumentRequest request) {
verifyKnowledgeBaseOwnership(kbId); // 还需要验证是否是本租户的知识库
return kbService.uploadDocument(kbId, request);
}
// 删除知识库:只有管理员可以
@DeleteMapping("/{kbId}")
@PreAuthorize("hasRole('TENANT_ADMIN')")
public void delete(@PathVariable String kbId) {
verifyKnowledgeBaseOwnership(kbId);
kbService.delete(kbId);
}
// 分享知识库给其他用户:需要特殊权限
@PostMapping("/{kbId}/share")
@PreAuthorize("hasRole('TENANT_ADMIN') and hasAuthority('FEATURE_KB_SHARING')")
public void share(@PathVariable String kbId,
@RequestBody ShareRequest request) {
kbService.share(kbId, request);
}
private void verifyKnowledgeBaseOwnership(String kbId) {
String currentTenantId = TenantContextHolder.get().getTenantId();
KnowledgeBase kb = kbService.findById(kbId);
if (!currentTenantId.equals(kb.getTenantId())) {
throw new AccessDeniedException("无权访问其他租户的知识库");
}
}
}审计日志:记录所有跨租户操作
多租户系统里审计日志非常重要,要能追溯每个操作是谁在哪个租户下做的:
@Aspect
@Component
@Slf4j
public class TenantAuditAspect {
private final AuditLogRepository auditRepository;
@Around("@annotation(auditLog)")
public Object audit(ProceedingJoinPoint pjp, TenantAuditLog auditLog)
throws Throwable {
TenantContext tenant = TenantContextHolder.getOptional().orElse(null);
String userId = getCurrentUserId();
long startTime = System.currentTimeMillis();
Object result = null;
String errorMessage = null;
try {
result = pjp.proceed();
return result;
} catch (Throwable e) {
errorMessage = e.getMessage();
throw e;
} finally {
// 无论成功失败都记录
AuditLog log = AuditLog.builder()
.tenantId(tenant != null ? tenant.getTenantId() : "unknown")
.userId(userId)
.action(auditLog.action())
.resource(auditLog.resource())
.details(buildDetails(pjp, result))
.success(errorMessage == null)
.errorMessage(errorMessage)
.durationMs(System.currentTimeMillis() - startTime)
.timestamp(LocalDateTime.now())
.ipAddress(getClientIp())
.build();
auditRepository.saveAsync(log);
}
}
private String buildDetails(ProceedingJoinPoint pjp, Object result) {
// 把关键参数记录下来(注意脱敏)
StringBuilder sb = new StringBuilder();
Object[] args = pjp.getArgs();
if (args.length > 0) {
sb.append("params=").append(sanitizeArgs(args));
}
return sb.toString();
}
}
// 用注解标记需要审计的方法
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TenantAuditLog {
String action();
String resource() default "";
}
// 使用示例
@PostMapping
@TenantAuditLog(action = "CREATE_KNOWLEDGE_BASE", resource = "KnowledgeBase")
public KnowledgeBase create(@RequestBody CreateKBRequest request) {
return kbService.create(request);
}数据隔离的最终防线:JPA Filter
除了应用层的隔离,在数据访问层再加一道保护——JPA的@Filter注解:
// 在Entity上配置过滤器
@Entity
@Table(name = "knowledge_bases")
@FilterDef(name = "tenantFilter",
parameters = @ParamDef(name = "tenantId", type = String.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@Data
public class KnowledgeBase {
@Id
private String id;
@Column(name = "tenant_id", nullable = false)
private String tenantId;
private String name;
// ...
}
// 自动激活过滤器的AOP
@Aspect
@Component
public class TenantFilterAspect {
private final EntityManager entityManager;
@Around("execution(* com.yourcompany.*.repository.*.*(..))")
public Object applyTenantFilter(ProceedingJoinPoint pjp) throws Throwable {
TenantContext tenant = TenantContextHolder.getOptional().orElse(null);
if (tenant == null) {
return pjp.proceed();
}
Session session = entityManager.unwrap(Session.class);
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenant.getTenantId());
try {
return pjp.proceed();
} finally {
session.disableFilter("tenantFilter");
}
}
}这样即使有人在Repository层忘了加where tenant_id = ?的条件,JPA Filter也会自动补上,作为最后一道防线。
多租户隔离的整体架构
多租户隔离是一个系统工程,需要在多个层次同时做防护。单靠任何一个层次都不够安全——应用层可能有逻辑疏漏,数据层的Filter是最后的保障。
