Java SpringAI

Spring AI with Role-Based Access Control — Secure AI Features Per User Role

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 @PreAuthorize at 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-Remaining response header so clients can display "8 of 10 daily requests used" to trial users without an extra API call
Topics: Java SpringAI
← Newer Post Older Post →