Spring AI with Role-Based Access Control — Secure AI Features Per User Role
Not all users should have equal access to AI features. Administrators may need full code analysis, premium users get unlimited queries, and trial users get limited hints. This tutorial integrates Spring Security with Spring AI to enforce role-based AI feature access, rate limiting per tier, and audit logging of all AI calls.
Access Control Matrix
Feature TRIAL PRO ADMIN
──────────────────────────────────────────────────────
Basic chat ✔ (10/day) ✔ (unlimited) ✔ (unlimited)
RAG document search ✘ ✔ ✔
Code review ✘ ✔ (gpt-4o-mini) ✔ (gpt-4o)
Full code generation ✘ ✔ ✔
Admin analytics ✘ ✘ ✔
Custom system prompts ✘ ✘ ✔
Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/ai/chat").hasAnyRole("TRIAL", "PRO", "ADMIN")
.requestMatchers("/ai/rag/**").hasAnyRole("PRO", "ADMIN")
.requestMatchers("/ai/code-review").hasAnyRole("PRO", "ADMIN")
.requestMatchers("/ai/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
}
Method-Level Security on AI Services
@Service
public class SecuredAiService {
private final ChatClient standardClient;
private final ChatClient premiumClient;
public SecuredAiService(ChatClient.Builder builder) {
this.standardClient = builder
.defaultOptions(OpenAiChatOptions.builder()
.withModel("gpt-4o-mini")
.build())
.build();
this.premiumClient = builder
.defaultOptions(OpenAiChatOptions.builder()
.withModel("gpt-4o")
.build())
.build();
}
@PreAuthorize("hasAnyRole('TRIAL', 'PRO', 'ADMIN')")
public String basicChat(String question) {
return standardClient.prompt().user(question).call().content();
}
@PreAuthorize("hasAnyRole('PRO', 'ADMIN')")
public String ragSearch(String question) {
// RAG search is PRO+ only
return premiumClient.prompt()
.user(question)
.advisors(new QuestionAnswerAdvisor(vectorStore))
.call().content();
}
@PreAuthorize("hasRole('ADMIN')")
public String deepCodeReview(String code) {
// Full GPT-4o code review is ADMIN only
return premiumClient.prompt()
.system("Perform an exhaustive security and performance code review.")
.user(code)
.call().content();
}
@PreAuthorize("hasAnyRole('PRO', 'ADMIN')")
public String codeReview(String code) {
// Standard code review for PRO
return standardClient.prompt()
.system("Review this Java code for common issues.")
.user(code)
.call().content();
}
}
Rate Limiting Per User Tier
@Service
public class AiRateLimiter {
private final RedisTemplate<String, String> redis;
private static final Map<String, Integer> DAILY_LIMITS = Map.of(
"ROLE_TRIAL", 10,
"ROLE_PRO", 500,
"ROLE_ADMIN", Integer.MAX_VALUE
);
public void checkAndIncrement(String userId, String role) {
String key = "ai:rate:" + userId + ":" + LocalDate.now();
String value = redis.opsForValue().get(key);
int current = value == null ? 0 : Integer.parseInt(value);
int limit = DAILY_LIMITS.getOrDefault(role, 10);
if (current >= limit) {
throw new RateLimitExceededException(
"Daily AI request limit reached (" + limit + "). " +
"Upgrade to Pro for higher limits.");
}
redis.opsForValue().increment(key);
redis.expire(key, Duration.ofDays(1));
}
}
// Apply rate limiting in the controller
@PostMapping("/chat")
public String chat(@RequestBody ChatRequest req, Authentication auth) {
String userId = auth.getName();
String role = auth.getAuthorities().iterator().next().getAuthority();
rateLimiter.checkAndIncrement(userId, role);
return aiService.basicChat(req.message());
}
AI Audit Logging
@Entity
@Table(name = "ai_audit_log")
public class AiAuditEntry {
@Id @GeneratedValue
private UUID id;
private String userId;
private String role;
private String feature; // "basic_chat", "rag_search", "code_review"
private String model;
private int inputTokens;
private int outputTokens;
private long latencyMs;
private boolean success;
private LocalDateTime timestamp;
}
@Component
public class AiAuditAdvisor implements CallAroundAdvisor {
private final AiAuditRepository auditRepo;
private final Authentication auth;
@Override
public AdvisedResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) {
long start = System.currentTimeMillis();
AdvisedResponse response = chain.nextAroundCall(request);
// Log the AI call
Usage usage = response.response().getMetadata().getUsage();
AiAuditEntry entry = new AiAuditEntry();
entry.setUserId(auth.getName());
entry.setInputTokens(usage.getPromptTokens());
entry.setOutputTokens(usage.getGenerationTokens());
entry.setLatencyMs(System.currentTimeMillis() - start);
entry.setSuccess(true);
entry.setTimestamp(LocalDateTime.now());
auditRepo.save(entry);
return response;
}
@Override public int getOrder() { return Ordered.LOWEST_PRECEDENCE; }
@Override public String getName() { return "AiAuditAdvisor"; }
}
Output
// TRIAL user tries to access RAG endpoint
POST /ai/rag/search { "question": "..." }
→ HTTP 403 Forbidden
{"error": "Access Denied: PRO or ADMIN role required"}
// TRIAL user exceeds daily limit (10/day)
POST /ai/chat { "message": "question 11" }
→ HTTP 429 Too Many Requests
{"error": "Daily AI request limit reached (10). Upgrade to Pro for higher limits."}
// Admin audit query
GET /ai/admin/usage?userId=user-123&date=2026-06-12
→ {
"userId": "user-123",
"role": "ROLE_PRO",
"totalRequests": 47,
"totalTokens": 28450,
"features": {"basic_chat": 40, "rag_search": 7}
}
Key Points
- Use
@PreAuthorizeat the service layer (not just the controller) to prevent AI feature access even from internal service calls - Store rate limit counters in Redis with a TTL of 1 day — Redis is the right tool here because it handles atomic increment operations efficiently at scale
- Log every AI call to an audit table — it provides billing data, abuse detection, and debugging capability all in one
- Grant ADMIN the premium model explicitly (gpt-4o) even when PRO uses gpt-4o-mini — power users need the best quality for complex analysis
- Add a
X-RateLimit-Remainingresponse header so clients can display "8 of 10 daily requests used" to trial users without an extra API call
Comments