Spring AI · AI tətbiqlərində təhlükəsizlik · Dərs 1
Spring Security ilə giriş hüquqları
Spring Security ilə istifadəçi icazələrini yoxla. Alət çağırışlarını və RAG üçün istifadə olunan sənədləri giriş hüquqlarına uyğun məhdudlaşdır.
Spring Security ilə giriş hüquqları
1. Kimliyi icazədən ayırın
Study Coach artıq planları oxuyan alətlərə və sənədləri tapan RAG axınına malikdir. Tələbənin kimliyi serverin autentifikasiya prosesindən gəlməlidir. Modelin “Mən Alice-əm” deməsi, UI-də düymənin gizlədilməsi və ya sorğuda tenant=academy-a yazılması bu kimliyi təsdiqləmir.
Tenant şəxsi məlumatı başqa qruplardan ayrılmalı olan təşkilat və ya hesab sahəsidir. Autentifikasiya sorğunu kimin etdiyini müəyyənləşdirir. Avtorizasiya həmin principal-ın konkret resurs üzərində konkret əməliyyatı indi edə bilib-bilmədiyinə qərar verir. Autentifikasiya olunmaq, eyni tenant-a aid olmaq və planın sahibi olmaq ayrı şərtlərdir. Təsdiqlənmiş authority, serverdə qüvvədə olan üzvlük, uyğun tenant və sahiblik tələb edəcəyik.
Bu modul ayrıca Java 21 laboratoriyasında süni tələbə və planlardan istifadə edir. Əvvəlki nümunələri dəyişmədən Study Coach mövzusunu davam etdirir. Spring Security-nin həqiqi filtr zənciri və test autentifikasiya obyektləri ilə səlahiyyət yoxlamasını sınaqdan keçirir. Parolla giriş qurmur, JWT yoxlamır və kimlik provayderinə qoşulmur.
2. Təkrarlana bilən təhlükəsizlik laboratoriyası qurun
security-lab yaradın və aşağıdakı POM-u saxlayın. Java 21 və Maven 3.9.x istifadə edin. Spring Boot 3.5.9 asılılıq bazasını, o cümlədən Spring Security 6.5.7 və Jackson 2.19.4 versiyalarını təmin edir. Spring AI 1.1.8 versiyasına sabitlənib. Testlər açıq şəkildə verilən Spring MVC və Security konfiqurasiyasını yükləyir. Bu, yerləşdirməyə hazır giriş serveri deyil, səlahiyyətləri yoxlamaq üçün layihədir.
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.9</version><relativePath/></parent>
<groupId>academy.study</groupId><artifactId>security-lab</artifactId><version>1.0.0</version>
<properties><java.version>21</java.version><spring-ai.version>1.1.8</spring-ai.version></properties>
<dependencyManagement><dependencies><dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-bom</artifactId><version>${spring-ai.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-model</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
</dependencies>
</project>Hər Java blokunu üzərində göstərilən yolda saxlayın. Kiçik audit saxlayıcısı əməliyyat, nəticə və səbəbin məhdud qiymətlərini qeyd edir; sorğu gövdəsi, giriş məlumatları və plan mətni yazılmır. Yaddaşda ən çox 100 qeyd saxlayır. Bu, laboratoriyanın yoxlama vasitəsidir, istehsal üçün dayanıqlı və ya tam audit jurnalı deyil.
src/main/java/academy/study/security/SecurityAudit.java
package academy.study.security;
import java.util.ArrayList;
import java.util.List;
public final class SecurityAudit {
public enum Action { READ_PLAN, READ_DOCUMENTS, TOOL_DISPATCH }
public enum Outcome { ALLOWED, DENIED }
public enum Reason { OK, NO_IDENTITY, NO_PERMISSION, UNAVAILABLE, INVALID_INPUT }
public record Entry(Action action, Outcome outcome, Reason reason) {}
private final List<Entry> entries = new ArrayList<>();
public synchronized void record(Action action, Outcome outcome, Reason reason) {
if (entries.size() == 100) entries.removeFirst();
entries.add(new Entry(action, outcome, reason));
}
public synchronized List<Entry> entries() { return List.copyOf(entries); }
}3. İcazəni resursa giriş nöqtəsində yoxlayın
Subject yalnız etibarlı autentifikasiya inteqrasiyasının yaratdığı principal tipidir. Təkcə onun tipi autentifikasiyanı sübut etmir. Testlər autentifikasiya mərhələsini bilərəkdən əvəz etdiyinə görə onu birbaşa yaradır. Yoxlanılmamış header, JWT payload və ya alət arqumentini heç vaxt autentifikasiya olunmuş tokenə çevirməyin.
Siyasət əməliyyat işləyəndə cari SecurityContextHolder-ı oxuyur. Anonim, autentifikasiya olunmamış və gözlənilməyən principal-ları rədd edir, sonra həm autentifikasiya authority-sini, həm də cari üzvlük qeydini yoxlayır. Tenant həmin qeyddən alınır. Əvvəldən verilmiş authority üzvlük saxlayıcısından silinmiş icazəni geri qaytara bilməz.
src/main/java/academy/study/security/StudyAccess.java
package academy.study.security;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import static academy.study.security.SecurityAudit.*;
public final class StudyAccess {
public enum Permission {
PLAN_READ("study:plan:read"), DOCUMENT_READ("study:document:read");
final String authority;
Permission(String authority) { this.authority = authority; }
}
public record Subject(String id) implements Principal {
@Override public String getName() { return id; }
}
public record Member(String subject, String tenant, Set<Permission> permissions) {
public Member { permissions = Set.copyOf(permissions); }
}
public record Plan(String id, String tenant, String owner, String summary) {}
private final Map<String, Member> members = new HashMap<>();
private final Map<String, Plan> plans = new HashMap<>();
private final SecurityAudit audit;
public StudyAccess(SecurityAudit audit) { this.audit = audit; }
// Trusted fixture administration, never exposed as HTTP or AI tools.
public synchronized void putMember(Member member) { members.put(member.subject(), member); }
public synchronized void removeMember(String subject) { members.remove(subject); }
public synchronized void putPlan(Plan plan) { plans.put(plan.id(), plan); }
public synchronized <T> T authorized(Permission permission, Action action,
Function<Member, T> operation) {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken
|| !(auth.getPrincipal() instanceof Subject subject)) {
throw denied(action, Reason.NO_IDENTITY);
}
var member = members.get(subject.id());
if (member == null || !member.permissions().contains(permission)
|| auth.getAuthorities().stream()
.noneMatch(a -> a.getAuthority().equals(permission.authority))) {
throw denied(action, Reason.NO_PERMISSION);
}
T result = operation.apply(member);
audit.record(action, Outcome.ALLOWED, Reason.OK);
return result;
}
public String readPlan(String id) {
return authorized(Permission.PLAN_READ, Action.READ_PLAN, member -> {
var plan = plans.get(id);
if (plan == null || !plan.tenant().equals(member.tenant())
|| !plan.owner().equals(member.subject())) {
throw denied(Action.READ_PLAN, Reason.UNAVAILABLE);
}
return plan.summary();
});
}
public AccessDeniedException denied(Action action, Reason reason) {
audit.record(action, Outcome.DENIED, reason);
return new AccessDeniedException("Request denied");
}
}readPlan planın xülasəsini qaytarmadan əvvəl onun tenant-ını və sahibini yoxlayır. Mövcud olmayan planla qadağan olunmuş plan eyni açıq xətanı qaytarır. Bu, resursun mövcudluğunun birbaşa açıqlanmasını azaldır; bütün zamanlama və resurs axtarışı hücumlarına qarşı sübut deyil. Test məlumatlarını idarə edən metodlar etibarlı qurulum əməliyyatlarıdır, HTTP endpoint və ya alət deyil.
Sinxronlaşdırılmış siyasət bir JVM monitorundan istifadə edir. Sonra sənəd icazəsinin dəyişməsi və prompt-un yığılması da eyni monitordan istifadə edəcək. Paylanmış tətbiqdə bunu ayrı-ayrı yaddaş xəritələri ilə deyil, etibarlı məlumat siyasəti və uyğun verilənlər bazası tranzaksiyası və ya kilidləmə qaydası ilə əvəz edin.
4. HTTP girişini standart olaraq bağlı saxlayın
Yalnız açıq göstərilmiş GET marşrutu autentifikasiya olunmuş trafiki qəbul edir. Bütün digər marşrut və metodlar rədd olunur. CSRF qoruması aktiv qalır. Controller resurs siyasətini çağırır. Buna görə HTTP sorğusunun autentifikasiyası başqa tələbənin planına giriş vermir.
src/main/java/academy/study/security/StudyWebConfiguration.java
package academy.study.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
@EnableWebMvc
public class StudyWebConfiguration {
@Bean SecurityAudit audit() { return new SecurityAudit(); }
@Bean StudyAccess access(SecurityAudit audit) { return new StudyAccess(audit); }
@Bean PlanController plans(StudyAccess access) { return new PlanController(access); }
@Bean SecurityFilterChain security(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(rules -> rules
.requestMatchers(HttpMethod.GET, "/api/study/plans/*").authenticated()
.anyRequest().denyAll()).build();
}
}src/main/java/academy/study/security/PlanController.java
package academy.study.security;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PlanController {
private final StudyAccess access;
public PlanController(StudyAccess access) { this.access = access; }
@GetMapping("/api/study/plans/{id}")
public String read(@PathVariable String id) { return access.readPlan(id); }
}Bu konfiqurasiyada giriş mexanizmi yoxdur. MockMvc Spring Security-nin test dəstəyi ilə test autentifikasiyasını ötürür. Testlər məhz bu quruluşda anonim sorğu üçün 403 gözləyir. Yerləşdirilmiş tətbiqin autentifikasiya giriş nöqtəsi 401 qaytara və ya yönləndirə bilər; həmin davranış ayrıca yoxlanmalıdır.
Təkcə HTTP filtri alətin və ya fon işinin birbaşa çağırdığı metodu qorumur. Bizim açıq servis siyasətimiz bu yollarda da işləyir. Başqa yerdə annotasiya əsaslı metod təhlükəsizliyi seçsəniz, onu ayrıca aktivləşdirib yoxlayın. Security starter əlavə etmək metod səlahiyyətlərini avtomatik aktivləşdirmir.
5. Sahibliyi, icazənin ləğvini və kontekstin ömrünü yoxlayın
Bu test məlumatlarında yalnız süni kimliklər var. Fayllar test mənbələri altında saxlanmalıdır. Autentifikasiya olunmuş token yaradan köməkçi istehsal autentifikasiya koduna çevrilməməlidir.
src/test/java/academy/study/security/SecurityFixtures.java
package academy.study.security;
import java.util.Arrays;
import java.util.Set;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import static academy.study.security.StudyAccess.*;
final class SecurityFixtures {
static void seed(StudyAccess access) {
var both = Set.of(Permission.PLAN_READ, Permission.DOCUMENT_READ);
access.putMember(new Member("alice", "academy-a", both));
access.putMember(new Member("bob", "academy-a", both));
access.putMember(new Member("carol", "academy-b", both));
access.putPlan(new Plan("plan-a", "academy-a", "alice", "Review Java queues for 15 minutes."));
access.putPlan(new Plan("plan-b", "academy-b", "carol", "Private tenant B plan"));
}
static Authentication authentication(String subject, Permission... permissions) {
var authorities = Arrays.stream(permissions)
.map(p -> new SimpleGrantedAuthority(p.authority)).toList();
return UsernamePasswordAuthenticationToken.authenticated(new Subject(subject), null, authorities);
}
static void signIn(String subject, Permission... permissions) {
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication(subject, permissions));
SecurityContextHolder.setContext(context);
}
}src/test/java/academy/study/security/PermissionTest.java
package academy.study.security;
import java.util.Set;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.concurrent.DelegatingSecurityContextCallable;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import static academy.study.security.StudyAccess.*;
import static org.assertj.core.api.Assertions.*;
class PermissionTest {
final SecurityAudit audit = new SecurityAudit();
final StudyAccess access = new StudyAccess(audit);
@BeforeEach void seed() { SecurityFixtures.seed(access); }
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
@Test void anonymousAndUntrustedPrincipalAreDenied() {
assertThatThrownBy(() -> access.readPlan("plan-a")).isInstanceOf(AccessDeniedException.class);
SecurityContextHolder.getContext().setAuthentication(new AnonymousAuthenticationToken("lab", "anon",
java.util.List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
assertThatThrownBy(() -> access.readPlan("plan-a")).isInstanceOf(AccessDeniedException.class);
SecurityContextHolder.getContext().setAuthentication(
UsernamePasswordAuthenticationToken.authenticated("alice", null,
java.util.List.of(new SimpleGrantedAuthority("study:plan:read"))));
assertThatThrownBy(() -> access.readPlan("plan-a")).isInstanceOf(AccessDeniedException.class);
}
@Test void ownerWithCurrentPermissionCanRead() {
SecurityFixtures.signIn("alice", Permission.PLAN_READ);
assertThat(access.readPlan("plan-a")).isEqualTo("Review Java queues for 15 minutes.");
assertThat(audit.entries()).contains(new SecurityAudit.Entry(SecurityAudit.Action.READ_PLAN,
SecurityAudit.Outcome.ALLOWED, SecurityAudit.Reason.OK));
}
@Test void missingAuthorityAndRevokedMembershipAreDenied() {
SecurityFixtures.signIn("alice");
assertThatThrownBy(() -> access.readPlan("plan-a")).isInstanceOf(AccessDeniedException.class);
SecurityFixtures.signIn("alice", Permission.PLAN_READ);
access.putMember(new Member("alice", "academy-a", Set.of()));
assertThatThrownBy(() -> access.readPlan("plan-a")).isInstanceOf(AccessDeniedException.class);
access.removeMember("alice");
assertThatThrownBy(() -> access.readPlan("plan-a")).isInstanceOf(AccessDeniedException.class);
}
@Test void sameTenantIsNotEnoughAndMissingIdsHaveTheSameError() {
SecurityFixtures.signIn("bob", Permission.PLAN_READ);
for (String id : new String[]{"plan-a", "plan-b", "missing"}) {
assertThatThrownBy(() -> access.readPlan(id)).isInstanceOf(AccessDeniedException.class)
.hasMessage("Request denied").hasNoCause();
}
assertThat(audit.entries()).hasSize(3).allSatisfy(e ->
assertThat(e.outcome()).isEqualTo(SecurityAudit.Outcome.DENIED));
}
@Test void workerRequiresExplicitContextAndChecksCurrentMembership() throws Exception {
SecurityFixtures.signIn("alice", Permission.PLAN_READ);
try (var executor = Executors.newSingleThreadExecutor()) {
assertThat(executor.submit(() -> {
try { access.readPlan("plan-a"); return false; }
catch (AccessDeniedException expected) { return true; }
}).get()).isTrue();
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(SecurityContextHolder.getContext().getAuthentication());
var task = new DelegatingSecurityContextCallable<>(() -> access.readPlan("plan-a"), context);
assertThat(executor.submit(task).get()).isEqualTo("Review Java queues for 15 minutes.");
assertThat(executor.submit(() -> SecurityContextHolder.getContext().getAuthentication()).get()).isNull();
access.removeMember("alice");
assertThatThrownBy(() -> executor.submit(task).get()).hasCauseInstanceOf(AccessDeniedException.class);
assertThat(executor.submit(() -> SecurityContextHolder.getContext().getAuthentication()).get()).isNull();
}
}
}Spring Security standart olaraq konteksti thread-local saxlayır. Worker testi əvvəlcə kimlik ötürülmədikdə girişin rədd olunduğunu göstərir, sonra ayrıca kontekstlə DelegatingSecurityContextCallable istifadə edir. Wrapper icradan sonra worker-in əvvəlki kontekstini bərpa edir və ya təmizləyir. Tapşırıq işləyəndə cari üzvlük yenə yoxlanılır. Ona görə autentifikasiya olunmuş tapşırığın növbəyə qoyulması sonradan ləğv edilmiş üzvlüyü qüvvədə saxlamır.
src/test/java/academy/study/security/WebPermissionTest.java
package academy.study.security;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static academy.study.security.StudyAccess.Permission.PLAN_READ;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringJUnitWebConfig(StudyWebConfiguration.class)
class WebPermissionTest {
@Autowired WebApplicationContext context;
@Autowired StudyAccess access;
MockMvc mvc;
@BeforeEach void setup() {
SecurityFixtures.seed(access);
mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
}
@Test void anonymousCannotRead() throws Exception {
mvc.perform(get("/api/study/plans/plan-a")).andExpect(status().isForbidden());
}
@Test void authenticatedOwnerCanReadButNextAnonymousRequestCannot() throws Exception {
mvc.perform(get("/api/study/plans/plan-a")
.with(authentication(SecurityFixtures.authentication("alice", PLAN_READ))))
.andExpect(status().isOk()).andExpect(content().string("Review Java queues for 15 minutes."));
mvc.perform(get("/api/study/plans/plan-a")).andExpect(status().isForbidden());
}
@Test void otherOwnerAndTenantCannotRead() throws Exception {
for (String subject : new String[]{"bob", "carol"}) {
mvc.perform(get("/api/study/plans/plan-a")
.with(authentication(SecurityFixtures.authentication(subject, PLAN_READ))))
.andExpect(status().isForbidden());
}
}
@Test void unmatchedRouteAndWriteMethodAreDenied() throws Exception {
var auth = authentication(SecurityFixtures.authentication("alice", PLAN_READ));
mvc.perform(get("/api/admin/export").with(auth)).andExpect(status().isForbidden());
mvc.perform(post("/api/study/plans/plan-a").with(auth).with(csrf()))
.andExpect(status().isForbidden());
}
}Doqquz testi işlədin:
mvn -B -Dtest=PermissionTest,WebPermissionTest testMockMvc şəbəkə portu açmadan konfiqurasiya edilmiş servlet filtrlərini və controller-ləri işlədir. Bu testlər səlahiyyət sərhədini və kontekstin təmizlənməsini yoxlayır. Həqiqi giriş mübadiləsi, sessiyanın bitməsi, token yoxlaması və istehsal proxy marşrutları yoxlanılmır.
6. Sübutun əhatəsini qiymətləndirin
Siyasət eyni tenant-da başqa sahibə, başqa tenant-a, silinmiş icazəyə və olmayan kimliyə giriş vermir. Test sorğusu düzgün CSRF tokeni versə də HTTP qaydaları uyğun olmayan marşrutları və yazma metodlarını rədd edir. UI-də linki gizlətmək bu yoxlamaların heç birini əvəz etmir.
Yaddaşdakı audit siyasət qərarlarını qeyd edir. HTTP filtr zəncirində daha əvvəl baş verən imtinalar bu saxlayıcıya çatmır. İstehsal auditinə autentifikasiya olunmuş subyekt və resurs istinadları, vaxt və korrelyasiya məlumatı, qorunan saxlama, saxlanma qaydaları, autentifikasiya və filtr xətalarının əhatəsi də lazımdır. Bu identifikatorları yüksək kardinalıqlı metrika etiketlərinə əlavə etməyin və prompt-ları loglara köçürməyin.
Tapşırıq: sənədləri oxuya bilən, planları isə oxuya bilməyən tələbə əlavə edin. UI plan linkini göstərsə də plan servisinin girişi rədd etdiyini yoxlayın. Növbəti dərs eyni siyasəti Spring AI tool callback-inin arxasında tətbiq edir.
Mənbələr: