Spring AI · AI tətbiqlərində təhlükəsizlik · Dərs 2
Alət çağırışlarına icazə nəzarəti
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.
Alət çağırışlarına icazə nəzarəti
1. Təklif edilən alət çağırışı etibar edilməyən sorğudur
Əvvəlki alətlər modulunda Study Coach plan təklifini insan təsdiqindən və saxlamadan ayırırdı. Bu ayrılıq yenə vacibdir. Burada daha kiçik oxuma əməliyyatını qoruyuruq: bir tələbənin mövcud planını tapmaq. Oxuma da icazə tələb edir, çünki yanlış planın qaytarılması şəxsi məlumatı aça bilər.
Model alətin adını və arqumentlərini seçir. Cari principal-ı seçmir, özünə icazə vermir və insan qərarını təsdiqləmir. Alətin təsviri nəzərdə tutulan istifadəni izah edə, giriş sxemi isə generasiyaya istiqamət verə bilər. Heç biri səlahiyyət sərhədi deyil.
Əvvəlki dərsin security-lab layihəsini davam etdirin. Spring AI 1.1.8-in həqiqi ToolCallback interfeysini həyata keçirib oflayn testlərdə birbaşa çağırırıq. Avtomatik model/alət dövrü işlətmirik və provayder çağırışı etmirik.
2. Arqumenti yoxlayın və resurs icazəsini təsdiqləyin
Modelin verdiyi yeganə sahə planId-dir. Callback əlavə sahələri, təkrarlanan açarları, ardınca gələn əlavə JSON-u, yanlış tipləri və həddən böyük girişi rədd edir. Düzgün identifikator da StudyAccess.readPlan yoxlamasından keçir. Sintaksisin düzgünlüyü sahibliyi sübut etmir.
src/main/java/academy/study/security/PlanTool.java
package academy.study.security;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import static academy.study.security.SecurityAudit.*;
public final class PlanTool implements ToolCallback {
private final StudyAccess access;
private final ObjectMapper json = new ObjectMapper()
.enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
private final ToolDefinition definition = ToolDefinition.builder().name("read_study_plan")
.description("Read one study plan already authorized for the current learner")
.inputSchema("""
{"type":"object","properties":{"planId":{"type":"string",
"pattern":"^[a-z0-9-]{1,40}$"}},"required":["planId"],"additionalProperties":false}
""").build();
public PlanTool(StudyAccess access) { this.access = access; }
@Override public ToolDefinition getToolDefinition() { return definition; }
@Override public String call(String input, ToolContext ignored) { return call(input); }
@Override public String call(String input) {
if (input == null || input.length() > 256) throw invalid();
String id;
try {
var node = json.readTree(input);
if (node == null || !node.isObject() || node.size() != 1 || !node.has("planId")
|| !node.get("planId").isTextual()) throw invalid();
id = node.get("planId").textValue();
if (!id.matches("[a-z0-9-]{1,40}")) throw invalid();
} catch (java.io.IOException malformed) {
throw invalid();
}
return access.readPlan(id);
}
private org.springframework.security.access.AccessDeniedException invalid() {
return access.denied(Action.READ_PLAN, Reason.INVALID_INPUT);
}
}Sabitlənmiş 2.19.4 versiyasında Jackson-un təkrarlanan açar və əlavə token yoxlamaları açıq şəkildə aktivləşdirilib. additionalProperties: false model üçün istənilən JSON formasını təsvir edir, Java yoxlamaları isə onu məcburi edir. 256 simvol həddi artıq yaradılmış arqument mətninə aiddir. Həqiqi HTTP və ya model cavabı sərhədində yaddaş ayrılmasından əvvəl ayrıca bayt, iç-içəlik və sorğu hədləri lazımdır.
Callback hər çağırışda kimliyi servis vasitəsilə yenidən oxuyur. Obyekt yaranarkən sistemdə olan tələbənin kimliyini daxilində saxlamır. Kontekst olmadıqda standart administrator kimliyi seçilmir.
3. ToolContext-i səlahiyyət qərarından kənarda saxlayın
Spring AI tətbiq məlumatını ToolContext ilə daşıya bilər. Bu xəritə modelə alət arqumentləri kimi göndərilmir. Ayrılıq faydalıdır, amma xəritə autentifikasiya protokolu və ya imzalanmış icazə sənədi deyil. Təhlükəsizliyi onu kimin yaratmasından və hansı sahələrin qəbul edilməsindən asılıdır.
Bizim overload xəritədən bilərəkdən istifadə etmir. Saxta subject, tenant və ya approved qeydi Spring Security-nin cari principal-ını və qüvvədə olan üzvlük qeydini əvəz edə bilməz. Həqiqi tətbiqdə client-in verdiyi xəritəni bütövlükdə təhlükəsizlik kontekstinə köçürməyin. İcra başqa thread-ə keçirsə, əvvəlki dərsdə yoxlanmış açıq kontekst ötürülməsindən istifadə edin və icra vaxtı cari siyasəti yenidən yoxlayın.
Callback sinxrondur. Reaktiv axınların və uzaq MCP serverlərinin kimlik ötürmə sərhədləri fərqlidir, onların ayrıca inteqrasiya testləri olmalıdır. Bu servlet thread-local nümunəsinin onları avtomatik qoruduğunu düşünməyin.
4. Yalnız sabit alət dəstinə yönləndirin
Router serverin müəyyən etdiyi bir alət adını saxlayır. Naməlum ad callback işləməzdən əvvəl rədd olunur. Verilən ada əsasən reflection işlətmir, class seçmir və modelin verdiyi URL-ni qəbul etmir.
src/main/java/academy/study/security/StudyToolRouter.java
package academy.study.security;
import java.util.Map;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import static academy.study.security.SecurityAudit.*;
public final class StudyToolRouter {
private final StudyAccess access;
private final Map<String, ToolCallback> callbacks;
public StudyToolRouter(StudyAccess access) {
this.access = access;
this.callbacks = Map.of("read_study_plan", new PlanTool(access));
}
public String invoke(String name, String arguments, ToolContext context) {
var callback = name == null ? null : callbacks.get(name);
if (callback == null) throw access.denied(Action.TOOL_DISPATCH, Reason.INVALID_INPUT);
return callback.call(arguments, context);
}
}İstehsalda model client-i yalnız öz tapşırığına uyğun alətləri təqdim etməlidir. Bu, lazımsız imkanların açılmasını azaldır, amma təqdim edilən siyahı süzülsə də callback səlahiyyət yoxlaması qalmalıdır. Çağıran tərəf nəzərdə tutulan UI və ya prompt-dan istifadə etmədən backend əməliyyatına müraciət edə bilər.
Yazma əməliyyatlarını ayrı saxlayın. Əvvəlki plan saxlama nümunəsini genişləndirsəniz, son yazma nöqtəsində cari principal-ı, sahibliyi, dəqiq təsdiqlənmiş təklifi, etibarlılıq müddətini və təkrar çağırışda dəyişməyən nəticəni yoxlayın. Təsdiq olduğunu iddia edən generasiya mətni serverin təsdiq qeydini əvəz edə bilməz. Bu müqavilələri yazmamaq üçün ümumi “istəniləni icra et” aləti əlavə etməyin.
5. Sərhədi oflayn testlərlə sınayın
Aşağıdakı yeddi testi test mənbələrində saxlayın. Onlar birinci dərsin süni kimliklərindən və siyasətindən istifadə edir. Bir test təsadüfi kimlik yadda saxlanmasını aşkar etmək üçün eyni callback obyektini fərqli principal-larla işlədir. Başqa test saxta ToolContext verir; siyasət yenə həqiqi cari tələbəni görür.
src/test/java/academy/study/security/ToolPermissionTest.java
package academy.study.security;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.context.SecurityContextHolder;
import static academy.study.security.StudyAccess.*;
import static org.assertj.core.api.Assertions.*;
class ToolPermissionTest {
final SecurityAudit audit = new SecurityAudit();
final StudyAccess access = new StudyAccess(audit);
final StudyToolRouter router = new StudyToolRouter(access);
@BeforeEach void seed() { SecurityFixtures.seed(access); }
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
String call(String input) { return router.invoke("read_study_plan", input, null); }
@Test void currentOwnerCanUseTheActualToolCallback() {
SecurityFixtures.signIn("alice", Permission.PLAN_READ);
assertThat(call("{\"planId\":\"plan-a\"}")).isEqualTo("Review Java queues for 15 minutes.");
}
@Test void callbackDoesNotCaptureItsCreatorsIdentity() {
SecurityFixtures.signIn("alice", Permission.PLAN_READ);
var callback = new PlanTool(access);
SecurityFixtures.signIn("bob", Permission.PLAN_READ);
assertThatThrownBy(() -> callback.call("{\"planId\":\"plan-a\"}"))
.isInstanceOf(AccessDeniedException.class);
}
@Test void toolContextCannotImpersonateAnotherLearner() {
SecurityFixtures.signIn("bob", Permission.PLAN_READ);
var forged = new ToolContext(Map.of("subject", "alice", "tenant", "academy-a", "approved", true));
assertThatThrownBy(() -> router.invoke("read_study_plan", "{\"planId\":\"plan-a\"}", forged))
.isInstanceOf(AccessDeniedException.class).hasMessage("Request denied");
}
@Test void unknownToolNamesAreNotDispatched() {
SecurityFixtures.signIn("alice", Permission.PLAN_READ);
for (String name : new String[]{"delete_all_plans", "java.lang.Runtime", "read_study_plan;admin"}) {
assertThatThrownBy(() -> router.invoke(name, "{}", null)).isInstanceOf(AccessDeniedException.class);
}
}
@Test void strictArgumentsRejectExtraDuplicateTrailingAndOversizedInput() {
SecurityFixtures.signIn("alice", Permission.PLAN_READ);
String[] invalid = {"{}", "[]", "null", "{", "{\"planId\":1}",
"{\"planId\":\"plan-a\",\"tenant\":\"academy-b\"}",
"{\"planId\":\"plan-a\",\"planId\":\"plan-b\"}",
"{\"planId\":\"plan-a\"} {}", "{\"planId\":\"../private\"}", "x".repeat(257)};
for (String input : invalid) assertThatThrownBy(() -> call(input))
.isInstanceOf(AccessDeniedException.class).hasMessage("Request denied").hasNoCause();
}
@Test void staleAuthenticationDoesNotOverrideRevokedMembership() {
SecurityFixtures.signIn("alice", Permission.PLAN_READ);
access.putMember(new Member("alice", "academy-a", Set.of()));
assertThatThrownBy(() -> call("{\"planId\":\"plan-a\"}")).isInstanceOf(AccessDeniedException.class);
}
@Test void denialAuditContainsNoRawArgumentOrPlanText() {
SecurityFixtures.signIn("bob", Permission.PLAN_READ);
assertThatThrownBy(() -> call("{\"planId\":\"plan-a\"}")).isInstanceOf(AccessDeniedException.class);
assertThat(audit.entries()).hasSize(1);
assertThat(audit.entries().toString()).doesNotContain("plan-a", "bob", "Review Java", "planId");
}
}security-lab qovluğunda işlədin:
mvn -B -Dtest=ToolPermissionTest testTestlər həqiqi callback müqaviləsini və router-imizi, sərt JSON yoxlamasını, cari icazənin ləğvini və minimal imtina auditini təsdiqləyir. Provayderin sxemi məcburi etməsini, avtomatik təkrar cəhd davranışını və alət seçiminin düzgünlüyünü yoxlamır. Həmin davranışlardan asılı olmayaraq server yoxlamaları lazımdır.
6. Rədd edilmiş çağırışı rədd edilmiş saxlayın
Rədd edilmiş çağırış plan mətni qaytarmır və daha səlahiyyətli kimliklə təkrar edilmir. Eyni axtarışı administrator kimi yenidən işlədərək modelə “kömək” etməyin. Açıq xəta mesajlarını ümumi saxlayın, əməliyyat diaqnostikası üçün qorunan audit qeydlərinə baxın.
İcazə yoxlaması icazəli alət nəticəsini etibarlı təlimata çevirmir. Plan, tapılmış səhifə və ya alət nəticəsində modeli başqa istiqamətə yönəltməyə çalışan mətn ola bilər. Alət dəstini məhdud saxlayın. Model həmin mətni izləyib başqa əməliyyat təklif etsə də eyni siyasəti tətbiq edin. Nəticəsi əhəmiyyətli olan yazma əməliyyatlarında dəqiq təsiri göstərən ayrıca insan təsdiqini qoruyun.
Tapşırıq: Alice autentifikasiya olunduğu halda modelin plan-b istədiyi test əlavə edin. Alət təsviri və ya prompt bütün planların açıq olduğunu desə belə düzgün nəticə imtinadır. Növbəti dərs eyni prinsipi sənəd axtarışına və prompt-a kontekst əlavə edilməsinə tətbiq edir.
Mənbələr: